diff --git a/packages/ipuaro/CHANGELOG.md b/packages/ipuaro/CHANGELOG.md index eaf54aa..e894dc5 100644 --- a/packages/ipuaro/CHANGELOG.md +++ b/packages/ipuaro/CHANGELOG.md @@ -5,6 +5,54 @@ 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.5.0] - 2025-12-01 - Read Tools + +### Added + +- **ToolRegistry (0.5.1)** + - `IToolRegistry` implementation for managing tool lifecycle + - Methods: `register()`, `unregister()`, `get()`, `getAll()`, `getByCategory()`, `has()` + - `execute()`: Tool execution with validation and confirmation flow + - `getToolDefinitions()`: Convert tools to LLM-compatible JSON Schema format + - Helper methods: `getConfirmationTools()`, `getSafeTools()`, `getNames()`, `clear()` + - 34 unit tests + +- **GetLinesTool (0.5.2)** + - `get_lines(path, start?, end?)`: Read file lines with line numbers + - Reads from Redis storage or filesystem fallback + - Line number formatting with proper padding + - Path validation (must be within project root) + - 25 unit tests + +- **GetFunctionTool (0.5.3)** + - `get_function(path, name)`: Get function source by name + - Uses AST to find exact line range + - Returns metadata: isAsync, isExported, params, returnType + - Lists available functions if target not found + - 20 unit tests + +- **GetClassTool (0.5.4)** + - `get_class(path, name)`: Get class source by name + - Uses AST to find exact line range + - Returns metadata: isAbstract, extends, implements, methods, properties + - Lists available classes if target not found + - 19 unit tests + +- **GetStructureTool (0.5.5)** + - `get_structure(path?, depth?)`: Get directory tree + - ASCII tree output with 📁/📄 icons + - Filters: node_modules, .git, dist, coverage, etc. + - Directories sorted before files + - Stats: directory and file counts + - 23 unit tests + +### Changed + +- Total tests: 540 (was 419) +- Coverage: 96%+ + +--- + ## [0.4.0] - 2025-11-30 - LLM Integration ### Added diff --git a/packages/ipuaro/package.json b/packages/ipuaro/package.json index d89dca5..60803a3 100644 --- a/packages/ipuaro/package.json +++ b/packages/ipuaro/package.json @@ -1,6 +1,6 @@ { "name": "@samiyev/ipuaro", - "version": "0.4.0", + "version": "0.5.0", "description": "Local AI agent for codebase operations with infinite context feeling", "author": "Fozilbek Samiyev ", "license": "MIT", diff --git a/packages/ipuaro/src/infrastructure/index.ts b/packages/ipuaro/src/infrastructure/index.ts index d0bd671..4fb4f91 100644 --- a/packages/ipuaro/src/infrastructure/index.ts +++ b/packages/ipuaro/src/infrastructure/index.ts @@ -2,3 +2,4 @@ export * from "./storage/index.js" export * from "./indexer/index.js" export * from "./llm/index.js" +export * from "./tools/index.js" diff --git a/packages/ipuaro/src/infrastructure/tools/index.ts b/packages/ipuaro/src/infrastructure/tools/index.ts new file mode 100644 index 0000000..c5dd578 --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/index.ts @@ -0,0 +1,12 @@ +// Tools module exports +export { ToolRegistry } from "./registry.js" + +// Read tools +export { GetLinesTool, type GetLinesResult } from "./read/GetLinesTool.js" +export { GetFunctionTool, type GetFunctionResult } from "./read/GetFunctionTool.js" +export { GetClassTool, type GetClassResult } from "./read/GetClassTool.js" +export { + GetStructureTool, + type GetStructureResult, + type TreeNode, +} from "./read/GetStructureTool.js" diff --git a/packages/ipuaro/src/infrastructure/tools/read/GetClassTool.ts b/packages/ipuaro/src/infrastructure/tools/read/GetClassTool.ts new file mode 100644 index 0000000..72e54fd --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/read/GetClassTool.ts @@ -0,0 +1,165 @@ +import { promises as fs } from "node:fs" +import * as path from "node:path" +import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js" +import type { ClassInfo } from "../../../domain/value-objects/FileAST.js" +import { + createErrorResult, + createSuccessResult, + type ToolResult, +} from "../../../domain/value-objects/ToolResult.js" + +/** + * Result data from get_class tool. + */ +export interface GetClassResult { + path: string + name: string + startLine: number + endLine: number + isExported: boolean + isAbstract: boolean + extends?: string + implements: string[] + methods: string[] + properties: string[] + content: string +} + +/** + * Tool for retrieving a class's source code by name. + * Uses AST to find exact line range. + */ +export class GetClassTool implements ITool { + readonly name = "get_class" + readonly description = + "Get a class's source code by name. Uses AST to find exact line range. " + + "Returns the class code with line numbers." + readonly parameters: ToolParameterSchema[] = [ + { + name: "path", + type: "string", + description: "File path relative to project root", + required: true, + }, + { + name: "name", + type: "string", + description: "Class name to retrieve", + required: true, + }, + ] + readonly requiresConfirmation = false + readonly category = "read" 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.name !== "string" || params.name.trim() === "") { + return "Parameter 'name' 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 className = params.name 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 ast = await ctx.storage.getAST(relativePath) + if (!ast) { + return createErrorResult( + callId, + `AST not found for "${relativePath}". File may not be indexed.`, + Date.now() - startTime, + ) + } + + const classInfo = this.findClass(ast.classes, className) + if (!classInfo) { + const available = ast.classes.map((c) => c.name).join(", ") || "none" + return createErrorResult( + callId, + `Class "${className}" not found in "${relativePath}". Available: ${available}`, + Date.now() - startTime, + ) + } + + const lines = await this.getFileLines(absolutePath, relativePath, ctx) + const classLines = lines.slice(classInfo.lineStart - 1, classInfo.lineEnd) + const content = this.formatLinesWithNumbers(classLines, classInfo.lineStart) + + const result: GetClassResult = { + path: relativePath, + name: classInfo.name, + startLine: classInfo.lineStart, + endLine: classInfo.lineEnd, + isExported: classInfo.isExported, + isAbstract: classInfo.isAbstract, + extends: classInfo.extends, + implements: classInfo.implements, + methods: classInfo.methods.map((m) => m.name), + properties: classInfo.properties.map((p) => p.name), + content, + } + + 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) + } + } + + /** + * Find class by name in AST. + */ + private findClass(classes: ClassInfo[], name: string): ClassInfo | undefined { + return classes.find((c) => c.name === name) + } + + /** + * Get file lines from storage or filesystem. + */ + private async getFileLines( + 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") + } + + /** + * Format lines with line numbers. + */ + private formatLinesWithNumbers(lines: string[], startLine: number): string { + const maxLineNum = startLine + lines.length - 1 + const padWidth = String(maxLineNum).length + + return lines + .map((line, index) => { + const lineNum = String(startLine + index).padStart(padWidth, " ") + return `${lineNum}│${line}` + }) + .join("\n") + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/read/GetFunctionTool.ts b/packages/ipuaro/src/infrastructure/tools/read/GetFunctionTool.ts new file mode 100644 index 0000000..3177b14 --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/read/GetFunctionTool.ts @@ -0,0 +1,161 @@ +import { promises as fs } from "node:fs" +import * as path from "node:path" +import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js" +import type { FunctionInfo } from "../../../domain/value-objects/FileAST.js" +import { + createErrorResult, + createSuccessResult, + type ToolResult, +} from "../../../domain/value-objects/ToolResult.js" + +/** + * Result data from get_function tool. + */ +export interface GetFunctionResult { + path: string + name: string + startLine: number + endLine: number + isAsync: boolean + isExported: boolean + params: string[] + returnType?: string + content: string +} + +/** + * Tool for retrieving a function's source code by name. + * Uses AST to find exact line range. + */ +export class GetFunctionTool implements ITool { + readonly name = "get_function" + readonly description = + "Get a function's source code by name. Uses AST to find exact line range. " + + "Returns the function code with line numbers." + readonly parameters: ToolParameterSchema[] = [ + { + name: "path", + type: "string", + description: "File path relative to project root", + required: true, + }, + { + name: "name", + type: "string", + description: "Function name to retrieve", + required: true, + }, + ] + readonly requiresConfirmation = false + readonly category = "read" 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.name !== "string" || params.name.trim() === "") { + return "Parameter 'name' 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 functionName = params.name 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 ast = await ctx.storage.getAST(relativePath) + if (!ast) { + return createErrorResult( + callId, + `AST not found for "${relativePath}". File may not be indexed.`, + Date.now() - startTime, + ) + } + + const functionInfo = this.findFunction(ast.functions, functionName) + if (!functionInfo) { + const available = ast.functions.map((f) => f.name).join(", ") || "none" + return createErrorResult( + callId, + `Function "${functionName}" not found in "${relativePath}". Available: ${available}`, + Date.now() - startTime, + ) + } + + const lines = await this.getFileLines(absolutePath, relativePath, ctx) + const functionLines = lines.slice(functionInfo.lineStart - 1, functionInfo.lineEnd) + const content = this.formatLinesWithNumbers(functionLines, functionInfo.lineStart) + + const result: GetFunctionResult = { + path: relativePath, + name: functionInfo.name, + startLine: functionInfo.lineStart, + endLine: functionInfo.lineEnd, + isAsync: functionInfo.isAsync, + isExported: functionInfo.isExported, + params: functionInfo.params.map((p) => p.name), + returnType: functionInfo.returnType, + content, + } + + 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) + } + } + + /** + * Find function by name in AST. + */ + private findFunction(functions: FunctionInfo[], name: string): FunctionInfo | undefined { + return functions.find((f) => f.name === name) + } + + /** + * Get file lines from storage or filesystem. + */ + private async getFileLines( + 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") + } + + /** + * Format lines with line numbers. + */ + private formatLinesWithNumbers(lines: string[], startLine: number): string { + const maxLineNum = startLine + lines.length - 1 + const padWidth = String(maxLineNum).length + + return lines + .map((line, index) => { + const lineNum = String(startLine + index).padStart(padWidth, " ") + return `${lineNum}│${line}` + }) + .join("\n") + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/read/GetLinesTool.ts b/packages/ipuaro/src/infrastructure/tools/read/GetLinesTool.ts new file mode 100644 index 0000000..3bfea0d --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/read/GetLinesTool.ts @@ -0,0 +1,158 @@ +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 get_lines tool. + */ +export interface GetLinesResult { + path: string + startLine: number + endLine: number + totalLines: number + content: string +} + +/** + * Tool for reading specific lines from a file. + * Returns content with line numbers. + */ +export class GetLinesTool implements ITool { + readonly name = "get_lines" + readonly description = + "Get specific lines from a file. Returns the content with line numbers. " + + "If no range is specified, returns the entire file." + 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: false, + }, + { + name: "end", + type: "number", + description: "End line number (1-based, inclusive)", + required: false, + }, + ] + readonly requiresConfirmation = false + readonly category = "read" 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 (params.start !== undefined) { + if (typeof params.start !== "number" || !Number.isInteger(params.start)) { + return "Parameter 'start' must be an integer" + } + if (params.start < 1) { + return "Parameter 'start' must be >= 1" + } + } + + if (params.end !== undefined) { + if (typeof params.end !== "number" || !Number.isInteger(params.end)) { + return "Parameter 'end' must be an integer" + } + if (params.end < 1) { + return "Parameter 'end' must be >= 1" + } + } + + if (params.start !== undefined && params.end !== undefined && params.start > params.end) { + return "Parameter 'start' must be <= 'end'" + } + + 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 lines = await this.getFileLines(absolutePath, relativePath, ctx) + const totalLines = lines.length + + let startLine = (params.start as number | undefined) ?? 1 + let endLine = (params.end as number | undefined) ?? totalLines + + startLine = Math.max(1, Math.min(startLine, totalLines)) + endLine = Math.max(startLine, Math.min(endLine, totalLines)) + + const selectedLines = lines.slice(startLine - 1, endLine) + const content = this.formatLinesWithNumbers(selectedLines, startLine) + + const result: GetLinesResult = { + path: relativePath, + startLine, + endLine, + totalLines, + content, + } + + 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 file lines from storage or filesystem. + */ + private async getFileLines( + 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") + } + + /** + * Format lines with line numbers. + * Example: " 1│const x = 1" + */ + private formatLinesWithNumbers(lines: string[], startLine: number): string { + const maxLineNum = startLine + lines.length - 1 + const padWidth = String(maxLineNum).length + + return lines + .map((line, index) => { + const lineNum = String(startLine + index).padStart(padWidth, " ") + return `${lineNum}│${line}` + }) + .join("\n") + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/read/GetStructureTool.ts b/packages/ipuaro/src/infrastructure/tools/read/GetStructureTool.ts new file mode 100644 index 0000000..45f08da --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/read/GetStructureTool.ts @@ -0,0 +1,205 @@ +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" +import { DEFAULT_IGNORE_PATTERNS } from "../../../domain/constants/index.js" + +/** + * Tree node representing a file or directory. + */ +export interface TreeNode { + name: string + type: "file" | "directory" + children?: TreeNode[] +} + +/** + * Result data from get_structure tool. + */ +export interface GetStructureResult { + path: string + tree: TreeNode + content: string + stats: { + directories: number + files: number + } +} + +/** + * Tool for getting project directory structure as a tree. + */ +export class GetStructureTool implements ITool { + readonly name = "get_structure" + readonly description = + "Get project directory structure as a tree. " + + "If path is specified, shows structure of that subdirectory only." + readonly parameters: ToolParameterSchema[] = [ + { + name: "path", + type: "string", + description: "Subdirectory path relative to project root (optional, defaults to root)", + required: false, + }, + { + name: "depth", + type: "number", + description: "Maximum depth to traverse (default: unlimited)", + required: false, + }, + ] + readonly requiresConfirmation = false + readonly category = "read" as const + + private readonly defaultIgnorePatterns = new Set([ + ...DEFAULT_IGNORE_PATTERNS, + ".git", + ".idea", + ".vscode", + "__pycache__", + ".pytest_cache", + ".nyc_output", + "coverage", + ]) + + validateParams(params: Record): string | null { + if (params.path !== undefined) { + if (typeof params.path !== "string") { + return "Parameter 'path' must be a string" + } + } + + if (params.depth !== undefined) { + if (typeof params.depth !== "number" || !Number.isInteger(params.depth)) { + return "Parameter 'depth' must be an integer" + } + if (params.depth < 1) { + return "Parameter 'depth' must be >= 1" + } + } + + 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 | undefined) ?? "" + const maxDepth = params.depth as number | undefined + 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 stat = await fs.stat(absolutePath) + if (!stat.isDirectory()) { + return createErrorResult( + callId, + `Path "${relativePath}" is not a directory`, + Date.now() - startTime, + ) + } + + const stats = { directories: 0, files: 0 } + const tree = await this.buildTree(absolutePath, maxDepth, 0, stats) + const content = this.formatTree(tree) + + const result: GetStructureResult = { + path: relativePath || ".", + tree, + content, + stats, + } + + 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) + } + } + + /** + * Build tree structure recursively. + */ + private async buildTree( + dirPath: string, + maxDepth: number | undefined, + currentDepth: number, + stats: { directories: number; files: number }, + ): Promise { + const name = path.basename(dirPath) || dirPath + const node: TreeNode = { name, type: "directory", children: [] } + stats.directories++ + + if (maxDepth !== undefined && currentDepth >= maxDepth) { + return node + } + + const entries = await fs.readdir(dirPath, { withFileTypes: true }) + const sortedEntries = entries + .filter((e) => !this.shouldIgnore(e.name)) + .sort((a, b) => { + if (a.isDirectory() && !b.isDirectory()) { + return -1 + } + if (!a.isDirectory() && b.isDirectory()) { + return 1 + } + return a.name.localeCompare(b.name) + }) + + for (const entry of sortedEntries) { + const entryPath = path.join(dirPath, entry.name) + + if (entry.isDirectory()) { + const childNode = await this.buildTree(entryPath, maxDepth, currentDepth + 1, stats) + node.children?.push(childNode) + } else if (entry.isFile()) { + node.children?.push({ name: entry.name, type: "file" }) + stats.files++ + } + } + + return node + } + + /** + * Check if entry should be ignored. + */ + private shouldIgnore(name: string): boolean { + return this.defaultIgnorePatterns.has(name) + } + + /** + * Format tree as ASCII art. + */ + private formatTree(node: TreeNode, prefix = "", isLast = true): string { + const lines: string[] = [] + const connector = isLast ? "└── " : "├── " + const icon = node.type === "directory" ? "📁 " : "📄 " + + lines.push(`${prefix}${connector}${icon}${node.name}`) + + if (node.children) { + const childPrefix = prefix + (isLast ? " " : "│ ") + const childCount = node.children.length + node.children.forEach((child, index) => { + const childIsLast = index === childCount - 1 + lines.push(this.formatTree(child, childPrefix, childIsLast)) + }) + } + + return lines.join("\n") + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/registry.ts b/packages/ipuaro/src/infrastructure/tools/registry.ts new file mode 100644 index 0000000..73ff3e4 --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/registry.ts @@ -0,0 +1,190 @@ +import type { IToolRegistry } from "../../application/interfaces/IToolRegistry.js" +import type { ITool, ToolContext, ToolParameterSchema } from "../../domain/services/ITool.js" +import { createErrorResult, type ToolResult } from "../../domain/value-objects/ToolResult.js" +import { IpuaroError } from "../../shared/errors/IpuaroError.js" + +/** + * Tool registry implementation. + * Manages registration and execution of tools. + */ +export class ToolRegistry implements IToolRegistry { + private readonly tools = new Map() + + /** + * Register a tool. + * @throws IpuaroError if tool with same name already registered + */ + register(tool: ITool): void { + if (this.tools.has(tool.name)) { + throw new IpuaroError( + "validation", + `Tool "${tool.name}" is already registered`, + true, + "Use a different tool name or unregister the existing tool first", + ) + } + this.tools.set(tool.name, tool) + } + + /** + * Unregister a tool by name. + * @returns true if tool was removed, false if not found + */ + unregister(name: string): boolean { + return this.tools.delete(name) + } + + /** + * Get tool by name. + */ + get(name: string): ITool | undefined { + return this.tools.get(name) + } + + /** + * Get all registered tools. + */ + getAll(): ITool[] { + return Array.from(this.tools.values()) + } + + /** + * Get tools by category. + */ + getByCategory(category: ITool["category"]): ITool[] { + return this.getAll().filter((tool) => tool.category === category) + } + + /** + * Check if tool exists. + */ + has(name: string): boolean { + return this.tools.has(name) + } + + /** + * Get number of registered tools. + */ + get size(): number { + return this.tools.size + } + + /** + * Execute tool by name. + * @throws IpuaroError if tool not found + */ + async execute( + name: string, + params: Record, + ctx: ToolContext, + ): Promise { + const startTime = Date.now() + const callId = `${name}-${String(startTime)}` + + const tool = this.tools.get(name) + if (!tool) { + return createErrorResult(callId, `Tool "${name}" not found`, Date.now() - startTime) + } + + const validationError = tool.validateParams(params) + if (validationError) { + return createErrorResult(callId, validationError, Date.now() - startTime) + } + + if (tool.requiresConfirmation) { + const confirmed = await ctx.requestConfirmation( + `Execute "${name}" with params: ${JSON.stringify(params)}`, + ) + if (!confirmed) { + return createErrorResult(callId, "User cancelled operation", Date.now() - startTime) + } + } + + try { + const result = await tool.execute(params, ctx) + return { + ...result, + callId, + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return createErrorResult(callId, message, Date.now() - startTime) + } + } + + /** + * Get tool definitions for LLM. + * Converts ITool[] to LLM-compatible format. + */ + getToolDefinitions(): { + name: string + description: string + parameters: { + type: "object" + properties: Record + required: string[] + } + }[] { + return this.getAll().map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: this.convertParametersToSchema(tool.parameters), + })) + } + + /** + * Convert tool parameters to JSON Schema format. + */ + private convertParametersToSchema(params: ToolParameterSchema[]): { + type: "object" + properties: Record + required: string[] + } { + const properties: Record = {} + const required: string[] = [] + + for (const param of params) { + properties[param.name] = { + type: param.type, + description: param.description, + } + if (param.required) { + required.push(param.name) + } + } + + return { + type: "object", + properties, + required, + } + } + + /** + * Clear all registered tools. + */ + clear(): void { + this.tools.clear() + } + + /** + * Get tool names. + */ + getNames(): string[] { + return Array.from(this.tools.keys()) + } + + /** + * Get tools that require confirmation. + */ + getConfirmationTools(): ITool[] { + return this.getAll().filter((tool) => tool.requiresConfirmation) + } + + /** + * Get tools that don't require confirmation. + */ + getSafeTools(): ITool[] { + return this.getAll().filter((tool) => !tool.requiresConfirmation) + } +} diff --git a/packages/ipuaro/tests/unit/infrastructure/llm/ResponseParser.test.ts b/packages/ipuaro/tests/unit/infrastructure/llm/ResponseParser.test.ts index 642bb55..7eba6dc 100644 --- a/packages/ipuaro/tests/unit/infrastructure/llm/ResponseParser.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/llm/ResponseParser.test.ts @@ -127,9 +127,7 @@ describe("ResponseParser", () => { describe("formatToolCallsAsXml", () => { it("should format tool calls as XML", () => { - const toolCalls = [ - createToolCall("1", "get_lines", { path: "src/index.ts", start: 1 }), - ] + const toolCalls = [createToolCall("1", "get_lines", { path: "src/index.ts", start: 1 })] const xml = formatToolCallsAsXml(toolCalls) @@ -152,9 +150,7 @@ describe("ResponseParser", () => { }) it("should handle object values as JSON", () => { - const toolCalls = [ - createToolCall("1", "test", { data: { key: "value" } }), - ] + const toolCalls = [createToolCall("1", "test", { data: { key: "value" } })] const xml = formatToolCallsAsXml(toolCalls) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/read/GetClassTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/read/GetClassTool.test.ts new file mode 100644 index 0000000..38006a2 --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/read/GetClassTool.test.ts @@ -0,0 +1,348 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { + GetClassTool, + type GetClassResult, +} from "../../../../../src/infrastructure/tools/read/GetClassTool.js" +import type { ToolContext } from "../../../../../src/domain/services/ITool.js" +import type { IStorage } from "../../../../../src/domain/services/IStorage.js" +import type { FileAST, ClassInfo } from "../../../../../src/domain/value-objects/FileAST.js" + +function createMockClass(overrides: Partial = {}): ClassInfo { + return { + name: "TestClass", + lineStart: 1, + lineEnd: 10, + methods: [ + { + name: "testMethod", + lineStart: 3, + lineEnd: 5, + params: [], + isAsync: false, + visibility: "public", + isStatic: false, + }, + ], + properties: [ + { + name: "testProp", + line: 2, + visibility: "private", + isStatic: false, + isReadonly: false, + }, + ], + implements: [], + isExported: true, + isAbstract: false, + ...overrides, + } +} + +function createMockAST(classes: ClassInfo[] = []): FileAST { + return { + imports: [], + exports: [], + functions: [], + classes, + interfaces: [], + typeAliases: [], + parseError: false, + } +} + +function createMockStorage( + fileData: { lines: string[] } | null = null, + ast: FileAST | null = null, +): IStorage { + return { + getFile: vi.fn().mockResolvedValue(fileData), + setFile: vi.fn(), + deleteFile: vi.fn(), + getAllFiles: vi.fn(), + getAST: vi.fn().mockResolvedValue(ast), + setAST: vi.fn(), + getMeta: vi.fn(), + setMeta: vi.fn(), + getSymbolIndex: vi.fn(), + setSymbolIndex: vi.fn(), + getDepsGraph: vi.fn(), + setDepsGraph: vi.fn(), + getConfig: vi.fn(), + setConfig: vi.fn(), + clear: vi.fn(), + } as unknown as IStorage +} + +function createMockContext(storage?: IStorage): ToolContext { + return { + projectRoot: "/test/project", + storage: storage ?? createMockStorage(), + requestConfirmation: vi.fn().mockResolvedValue(true), + onProgress: vi.fn(), + } +} + +describe("GetClassTool", () => { + let tool: GetClassTool + + beforeEach(() => { + tool = new GetClassTool() + }) + + describe("metadata", () => { + it("should have correct name", () => { + expect(tool.name).toBe("get_class") + }) + + it("should have correct category", () => { + expect(tool.category).toBe("read") + }) + + it("should not require confirmation", () => { + expect(tool.requiresConfirmation).toBe(false) + }) + + 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("name") + expect(tool.parameters[1].required).toBe(true) + }) + }) + + describe("validateParams", () => { + it("should return null for valid params", () => { + expect(tool.validateParams({ path: "src/index.ts", name: "MyClass" })).toBeNull() + }) + + it("should return error for missing path", () => { + expect(tool.validateParams({ name: "MyClass" })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for empty path", () => { + expect(tool.validateParams({ path: "", name: "MyClass" })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for missing name", () => { + expect(tool.validateParams({ path: "test.ts" })).toBe( + "Parameter 'name' is required and must be a non-empty string", + ) + }) + + it("should return error for empty name", () => { + expect(tool.validateParams({ path: "test.ts", name: "" })).toBe( + "Parameter 'name' is required and must be a non-empty string", + ) + }) + }) + + describe("execute", () => { + it("should return class code with line numbers", async () => { + const lines = [ + "export class TestClass {", + " private testProp: string", + " testMethod() {", + " return this.testProp", + " }", + "}", + ] + const cls = createMockClass({ + name: "TestClass", + lineStart: 1, + lineEnd: 6, + }) + const ast = createMockAST([cls]) + const storage = createMockStorage({ lines }, ast) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", name: "TestClass" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetClassResult + expect(data.path).toBe("test.ts") + expect(data.name).toBe("TestClass") + expect(data.startLine).toBe(1) + expect(data.endLine).toBe(6) + expect(data.content).toContain("1│export class TestClass {") + expect(data.content).toContain("6│}") + }) + + it("should return class metadata", async () => { + const lines = ["abstract class BaseService extends Service implements IService {", "}"] + const cls = createMockClass({ + name: "BaseService", + lineStart: 1, + lineEnd: 2, + isExported: false, + isAbstract: true, + extends: "Service", + implements: ["IService"], + methods: [ + { + name: "init", + lineStart: 2, + lineEnd: 2, + params: [], + isAsync: true, + visibility: "public", + isStatic: false, + }, + { + name: "destroy", + lineStart: 3, + lineEnd: 3, + params: [], + isAsync: false, + visibility: "protected", + isStatic: false, + }, + ], + properties: [ + { + name: "id", + line: 2, + visibility: "private", + isStatic: false, + isReadonly: true, + }, + ], + }) + const ast = createMockAST([cls]) + const storage = createMockStorage({ lines }, ast) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "service.ts", name: "BaseService" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetClassResult + expect(data.isExported).toBe(false) + expect(data.isAbstract).toBe(true) + expect(data.extends).toBe("Service") + expect(data.implements).toEqual(["IService"]) + expect(data.methods).toEqual(["init", "destroy"]) + expect(data.properties).toEqual(["id"]) + }) + + it("should return error when AST not found", async () => { + const storage = createMockStorage({ lines: [] }, null) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", name: "MyClass" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toContain('AST not found for "test.ts"') + }) + + it("should return error when class not found", async () => { + const ast = createMockAST([ + createMockClass({ name: "ClassA" }), + createMockClass({ name: "ClassB" }), + ]) + const storage = createMockStorage({ lines: [] }, ast) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", name: "NonExistent" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toContain('Class "NonExistent" not found') + expect(result.error).toContain("Available: ClassA, ClassB") + }) + + it("should return error when no classes available", async () => { + const ast = createMockAST([]) + const storage = createMockStorage({ lines: [] }, ast) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", name: "MyClass" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toContain("Available: none") + }) + + it("should return error for path outside project root", async () => { + const ctx = createMockContext() + + const result = await tool.execute({ path: "../outside/file.ts", name: "MyClass" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("Path must be within project root") + }) + + it("should handle class with no extends", async () => { + const lines = ["class Simple {}"] + const cls = createMockClass({ + name: "Simple", + lineStart: 1, + lineEnd: 1, + extends: undefined, + }) + const ast = createMockAST([cls]) + const storage = createMockStorage({ lines }, ast) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", name: "Simple" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetClassResult + expect(data.extends).toBeUndefined() + }) + + it("should handle class with empty implements", async () => { + const lines = ["class NoInterfaces {}"] + const cls = createMockClass({ + name: "NoInterfaces", + lineStart: 1, + lineEnd: 1, + implements: [], + }) + const ast = createMockAST([cls]) + const storage = createMockStorage({ lines }, ast) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", name: "NoInterfaces" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetClassResult + expect(data.implements).toEqual([]) + }) + + it("should handle class with no methods or properties", async () => { + const lines = ["class Empty {}"] + const cls = createMockClass({ + name: "Empty", + lineStart: 1, + lineEnd: 1, + methods: [], + properties: [], + }) + const ast = createMockAST([cls]) + const storage = createMockStorage({ lines }, ast) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", name: "Empty" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetClassResult + expect(data.methods).toEqual([]) + expect(data.properties).toEqual([]) + }) + + it("should include callId in result", async () => { + const lines = ["class Test {}"] + const cls = createMockClass({ name: "Test", lineStart: 1, lineEnd: 1 }) + const ast = createMockAST([cls]) + const storage = createMockStorage({ lines }, ast) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", name: "Test" }, ctx) + + expect(result.callId).toMatch(/^get_class-\d+$/) + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/read/GetFunctionTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/read/GetFunctionTool.test.ts new file mode 100644 index 0000000..9ed0288 --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/read/GetFunctionTool.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { + GetFunctionTool, + type GetFunctionResult, +} from "../../../../../src/infrastructure/tools/read/GetFunctionTool.js" +import type { ToolContext } from "../../../../../src/domain/services/ITool.js" +import type { IStorage } from "../../../../../src/domain/services/IStorage.js" +import type { FileAST, FunctionInfo } from "../../../../../src/domain/value-objects/FileAST.js" + +function createMockFunction(overrides: Partial = {}): FunctionInfo { + return { + name: "testFunction", + lineStart: 1, + lineEnd: 5, + params: [{ name: "arg1", optional: false, hasDefault: false }], + isAsync: false, + isExported: true, + returnType: "void", + ...overrides, + } +} + +function createMockAST(functions: FunctionInfo[] = []): FileAST { + return { + imports: [], + exports: [], + functions, + classes: [], + interfaces: [], + typeAliases: [], + parseError: false, + } +} + +function createMockStorage( + fileData: { lines: string[] } | null = null, + ast: FileAST | null = null, +): IStorage { + return { + getFile: vi.fn().mockResolvedValue(fileData), + setFile: vi.fn(), + deleteFile: vi.fn(), + getAllFiles: vi.fn(), + getAST: vi.fn().mockResolvedValue(ast), + setAST: vi.fn(), + getMeta: vi.fn(), + setMeta: vi.fn(), + getSymbolIndex: vi.fn(), + setSymbolIndex: vi.fn(), + getDepsGraph: vi.fn(), + setDepsGraph: vi.fn(), + getConfig: vi.fn(), + setConfig: vi.fn(), + clear: vi.fn(), + } as unknown as IStorage +} + +function createMockContext(storage?: IStorage): ToolContext { + return { + projectRoot: "/test/project", + storage: storage ?? createMockStorage(), + requestConfirmation: vi.fn().mockResolvedValue(true), + onProgress: vi.fn(), + } +} + +describe("GetFunctionTool", () => { + let tool: GetFunctionTool + + beforeEach(() => { + tool = new GetFunctionTool() + }) + + describe("metadata", () => { + it("should have correct name", () => { + expect(tool.name).toBe("get_function") + }) + + it("should have correct category", () => { + expect(tool.category).toBe("read") + }) + + it("should not require confirmation", () => { + expect(tool.requiresConfirmation).toBe(false) + }) + + 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("name") + expect(tool.parameters[1].required).toBe(true) + }) + }) + + describe("validateParams", () => { + it("should return null for valid params", () => { + expect(tool.validateParams({ path: "src/index.ts", name: "myFunc" })).toBeNull() + }) + + it("should return error for missing path", () => { + expect(tool.validateParams({ name: "myFunc" })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for empty path", () => { + expect(tool.validateParams({ path: "", name: "myFunc" })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for missing name", () => { + expect(tool.validateParams({ path: "test.ts" })).toBe( + "Parameter 'name' is required and must be a non-empty string", + ) + }) + + it("should return error for empty name", () => { + expect(tool.validateParams({ path: "test.ts", name: "" })).toBe( + "Parameter 'name' is required and must be a non-empty string", + ) + }) + + it("should return error for whitespace-only name", () => { + expect(tool.validateParams({ path: "test.ts", name: " " })).toBe( + "Parameter 'name' is required and must be a non-empty string", + ) + }) + }) + + describe("execute", () => { + it("should return function code with line numbers", async () => { + const lines = [ + "function testFunction(arg1) {", + " console.log(arg1)", + " return arg1", + "}", + "", + ] + const func = createMockFunction({ + name: "testFunction", + lineStart: 1, + lineEnd: 4, + }) + const ast = createMockAST([func]) + const storage = createMockStorage({ lines }, ast) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", name: "testFunction" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetFunctionResult + expect(data.path).toBe("test.ts") + expect(data.name).toBe("testFunction") + expect(data.startLine).toBe(1) + expect(data.endLine).toBe(4) + expect(data.content).toContain("1│function testFunction(arg1) {") + expect(data.content).toContain("4│}") + }) + + it("should return function metadata", async () => { + const lines = ["async function fetchData(url, options) {", " return fetch(url)", "}"] + const func = createMockFunction({ + name: "fetchData", + lineStart: 1, + lineEnd: 3, + isAsync: true, + isExported: false, + params: [ + { name: "url", optional: false, hasDefault: false }, + { name: "options", optional: true, hasDefault: false }, + ], + returnType: "Promise", + }) + const ast = createMockAST([func]) + const storage = createMockStorage({ lines }, ast) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "api.ts", name: "fetchData" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetFunctionResult + expect(data.isAsync).toBe(true) + expect(data.isExported).toBe(false) + expect(data.params).toEqual(["url", "options"]) + expect(data.returnType).toBe("Promise") + }) + + it("should return error when AST not found", async () => { + const storage = createMockStorage({ lines: [] }, null) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", name: "myFunc" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toContain('AST not found for "test.ts"') + }) + + it("should return error when function not found", async () => { + const ast = createMockAST([ + createMockFunction({ name: "existingFunc" }), + createMockFunction({ name: "anotherFunc" }), + ]) + const storage = createMockStorage({ lines: [] }, ast) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", name: "nonExistent" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toContain('Function "nonExistent" not found') + expect(result.error).toContain("Available: existingFunc, anotherFunc") + }) + + it("should return error when no functions available", async () => { + const ast = createMockAST([]) + const storage = createMockStorage({ lines: [] }, ast) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", name: "myFunc" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toContain("Available: none") + }) + + it("should return error for path outside project root", async () => { + const ctx = createMockContext() + + const result = await tool.execute({ path: "../outside/file.ts", name: "myFunc" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("Path must be within project root") + }) + + it("should pad line numbers correctly for large files", async () => { + const lines = Array.from({ length: 200 }, (_, i) => `line ${i + 1}`) + const func = createMockFunction({ + name: "bigFunction", + lineStart: 95, + lineEnd: 105, + }) + const ast = createMockAST([func]) + const storage = createMockStorage({ lines }, ast) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "big.ts", name: "bigFunction" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetFunctionResult + expect(data.content).toContain(" 95│line 95") + expect(data.content).toContain("100│line 100") + expect(data.content).toContain("105│line 105") + }) + + it("should include callId in result", async () => { + const lines = ["function test() {}"] + const func = createMockFunction({ name: "test", lineStart: 1, lineEnd: 1 }) + const ast = createMockAST([func]) + const storage = createMockStorage({ lines }, ast) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", name: "test" }, ctx) + + expect(result.callId).toMatch(/^get_function-\d+$/) + }) + + it("should handle function with no return type", async () => { + const lines = ["function noReturn() {}"] + const func = createMockFunction({ + name: "noReturn", + lineStart: 1, + lineEnd: 1, + returnType: undefined, + }) + const ast = createMockAST([func]) + const storage = createMockStorage({ lines }, ast) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", name: "noReturn" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetFunctionResult + expect(data.returnType).toBeUndefined() + }) + + it("should handle function with no params", async () => { + const lines = ["function noParams() {}"] + const func = createMockFunction({ + name: "noParams", + lineStart: 1, + lineEnd: 1, + params: [], + }) + const ast = createMockAST([func]) + const storage = createMockStorage({ lines }, ast) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", name: "noParams" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetFunctionResult + expect(data.params).toEqual([]) + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/read/GetLinesTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/read/GetLinesTool.test.ts new file mode 100644 index 0000000..88f2ef8 --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/read/GetLinesTool.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { + GetLinesTool, + type GetLinesResult, +} from "../../../../../src/infrastructure/tools/read/GetLinesTool.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(), + deleteFile: vi.fn(), + getAllFiles: vi.fn(), + getAST: vi.fn(), + setAST: vi.fn(), + getMeta: vi.fn(), + setMeta: vi.fn(), + getSymbolIndex: vi.fn(), + setSymbolIndex: vi.fn(), + getDepsGraph: vi.fn(), + setDepsGraph: vi.fn(), + getConfig: vi.fn(), + setConfig: vi.fn(), + clear: vi.fn(), + } as unknown as IStorage +} + +function createMockContext(storage?: IStorage): ToolContext { + return { + projectRoot: "/test/project", + storage: storage ?? createMockStorage(), + requestConfirmation: vi.fn().mockResolvedValue(true), + onProgress: vi.fn(), + } +} + +describe("GetLinesTool", () => { + let tool: GetLinesTool + + beforeEach(() => { + tool = new GetLinesTool() + }) + + describe("metadata", () => { + it("should have correct name", () => { + expect(tool.name).toBe("get_lines") + }) + + it("should have correct category", () => { + expect(tool.category).toBe("read") + }) + + it("should not require confirmation", () => { + expect(tool.requiresConfirmation).toBe(false) + }) + + it("should have correct parameters", () => { + expect(tool.parameters).toHaveLength(3) + 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(false) + expect(tool.parameters[2].name).toBe("end") + expect(tool.parameters[2].required).toBe(false) + }) + }) + + describe("validateParams", () => { + it("should return null for valid params with path only", () => { + expect(tool.validateParams({ path: "src/index.ts" })).toBeNull() + }) + + it("should return null for valid params with start and end", () => { + expect(tool.validateParams({ path: "src/index.ts", start: 1, end: 10 })).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", + ) + }) + + it("should return error for non-integer start", () => { + expect(tool.validateParams({ path: "test.ts", start: 1.5 })).toBe( + "Parameter 'start' must be an integer", + ) + expect(tool.validateParams({ path: "test.ts", start: "1" })).toBe( + "Parameter 'start' must be an integer", + ) + }) + + it("should return error for start < 1", () => { + expect(tool.validateParams({ path: "test.ts", start: 0 })).toBe( + "Parameter 'start' must be >= 1", + ) + expect(tool.validateParams({ path: "test.ts", start: -1 })).toBe( + "Parameter 'start' must be >= 1", + ) + }) + + it("should return error for non-integer end", () => { + expect(tool.validateParams({ path: "test.ts", end: 1.5 })).toBe( + "Parameter 'end' must be an integer", + ) + }) + + it("should return error for end < 1", () => { + expect(tool.validateParams({ path: "test.ts", end: 0 })).toBe( + "Parameter 'end' must be >= 1", + ) + }) + + it("should return error for start > end", () => { + expect(tool.validateParams({ path: "test.ts", start: 10, end: 5 })).toBe( + "Parameter 'start' must be <= 'end'", + ) + }) + }) + + describe("execute", () => { + it("should return all lines when no range specified", async () => { + const lines = ["line 1", "line 2", "line 3"] + const storage = createMockStorage({ lines }) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetLinesResult + expect(data.path).toBe("test.ts") + expect(data.startLine).toBe(1) + expect(data.endLine).toBe(3) + expect(data.totalLines).toBe(3) + expect(data.content).toContain("1│line 1") + expect(data.content).toContain("2│line 2") + expect(data.content).toContain("3│line 3") + }) + + it("should return specific range", async () => { + const lines = ["line 1", "line 2", "line 3", "line 4", "line 5"] + const storage = createMockStorage({ lines }) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", start: 2, end: 4 }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetLinesResult + expect(data.startLine).toBe(2) + expect(data.endLine).toBe(4) + expect(data.content).toContain("2│line 2") + expect(data.content).toContain("3│line 3") + expect(data.content).toContain("4│line 4") + expect(data.content).not.toContain("line 1") + expect(data.content).not.toContain("line 5") + }) + + it("should clamp start to 1 if less", async () => { + const lines = ["line 1", "line 2"] + const storage = createMockStorage({ lines }) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", start: -5, end: 2 }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetLinesResult + expect(data.startLine).toBe(1) + }) + + it("should clamp end to totalLines if greater", async () => { + const lines = ["line 1", "line 2", "line 3"] + const storage = createMockStorage({ lines }) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", start: 1, end: 100 }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetLinesResult + expect(data.endLine).toBe(3) + }) + + it("should pad line numbers correctly", async () => { + const lines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`) + const storage = createMockStorage({ lines }) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts", start: 98, end: 100 }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetLinesResult + expect(data.content).toContain(" 98│line 98") + expect(data.content).toContain(" 99│line 99") + expect(data.content).toContain("100│line 100") + }) + + it("should return error for path outside project root", async () => { + const ctx = createMockContext() + + 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 when file not found", async () => { + const storage = createMockStorage(null) + storage.getFile = vi.fn().mockResolvedValue(null) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "nonexistent.ts" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toContain("ENOENT") + }) + + it("should include callId in result", async () => { + const storage = createMockStorage({ lines: ["test"] }) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts" }, ctx) + + expect(result.callId).toMatch(/^get_lines-\d+$/) + }) + + it("should include executionTimeMs in result", async () => { + const storage = createMockStorage({ lines: ["test"] }) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "test.ts" }, ctx) + + expect(result.executionTimeMs).toBeGreaterThanOrEqual(0) + }) + + it("should handle empty file", async () => { + const storage = createMockStorage({ lines: [] }) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "empty.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetLinesResult + expect(data.totalLines).toBe(0) + expect(data.content).toBe("") + }) + + it("should handle single line file", async () => { + const storage = createMockStorage({ lines: ["only line"] }) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "single.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetLinesResult + expect(data.totalLines).toBe(1) + expect(data.content).toBe("1│only line") + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/read/GetStructureTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/read/GetStructureTool.test.ts new file mode 100644 index 0000000..523acad --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/read/GetStructureTool.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 { + GetStructureTool, + type GetStructureResult, +} from "../../../../../src/infrastructure/tools/read/GetStructureTool.js" +import type { ToolContext } from "../../../../../src/domain/services/ITool.js" +import type { IStorage } from "../../../../../src/domain/services/IStorage.js" + +function createMockStorage(): IStorage { + return { + getFile: vi.fn(), + setFile: vi.fn(), + deleteFile: vi.fn(), + getAllFiles: vi.fn(), + getAST: vi.fn(), + setAST: vi.fn(), + getMeta: vi.fn(), + setMeta: vi.fn(), + getSymbolIndex: vi.fn(), + setSymbolIndex: vi.fn(), + getDepsGraph: vi.fn(), + setDepsGraph: vi.fn(), + getConfig: vi.fn(), + setConfig: vi.fn(), + clear: vi.fn(), + } as unknown as IStorage +} + +function createMockContext(projectRoot: string): ToolContext { + return { + projectRoot, + storage: createMockStorage(), + requestConfirmation: vi.fn().mockResolvedValue(true), + onProgress: vi.fn(), + } +} + +describe("GetStructureTool", () => { + let tool: GetStructureTool + let tempDir: string + + beforeEach(async () => { + tool = new GetStructureTool() + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ipuaro-test-")) + }) + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + describe("metadata", () => { + it("should have correct name", () => { + expect(tool.name).toBe("get_structure") + }) + + it("should have correct category", () => { + expect(tool.category).toBe("read") + }) + + it("should not require confirmation", () => { + expect(tool.requiresConfirmation).toBe(false) + }) + + it("should have correct parameters", () => { + expect(tool.parameters).toHaveLength(2) + expect(tool.parameters[0].name).toBe("path") + expect(tool.parameters[0].required).toBe(false) + expect(tool.parameters[1].name).toBe("depth") + expect(tool.parameters[1].required).toBe(false) + }) + }) + + describe("validateParams", () => { + it("should return null for empty params", () => { + expect(tool.validateParams({})).toBeNull() + }) + + it("should return null for valid path", () => { + expect(tool.validateParams({ path: "src" })).toBeNull() + }) + + it("should return null for valid depth", () => { + expect(tool.validateParams({ depth: 3 })).toBeNull() + }) + + it("should return error for non-string path", () => { + expect(tool.validateParams({ path: 123 })).toBe("Parameter 'path' must be a string") + }) + + it("should return error for non-integer depth", () => { + expect(tool.validateParams({ depth: 2.5 })).toBe("Parameter 'depth' must be an integer") + }) + + it("should return error for depth < 1", () => { + expect(tool.validateParams({ depth: 0 })).toBe("Parameter 'depth' must be >= 1") + }) + }) + + describe("execute", () => { + it("should return tree structure for empty directory", async () => { + const ctx = createMockContext(tempDir) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetStructureResult + expect(data.path).toBe(".") + expect(data.tree.type).toBe("directory") + expect(data.tree.children).toEqual([]) + expect(data.stats.directories).toBe(1) + expect(data.stats.files).toBe(0) + }) + + it("should return tree structure with files", async () => { + await fs.writeFile(path.join(tempDir, "file1.ts"), "") + await fs.writeFile(path.join(tempDir, "file2.ts"), "") + const ctx = createMockContext(tempDir) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetStructureResult + expect(data.tree.children).toHaveLength(2) + expect(data.stats.files).toBe(2) + expect(data.content).toContain("file1.ts") + expect(data.content).toContain("file2.ts") + }) + + it("should return nested directory structure", async () => { + await fs.mkdir(path.join(tempDir, "src")) + await fs.writeFile(path.join(tempDir, "src", "index.ts"), "") + await fs.mkdir(path.join(tempDir, "src", "utils")) + await fs.writeFile(path.join(tempDir, "src", "utils", "helper.ts"), "") + const ctx = createMockContext(tempDir) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetStructureResult + expect(data.stats.directories).toBe(3) + expect(data.stats.files).toBe(2) + expect(data.content).toContain("src") + expect(data.content).toContain("index.ts") + expect(data.content).toContain("utils") + expect(data.content).toContain("helper.ts") + }) + + it("should respect depth parameter", async () => { + await fs.mkdir(path.join(tempDir, "level1")) + await fs.mkdir(path.join(tempDir, "level1", "level2")) + await fs.mkdir(path.join(tempDir, "level1", "level2", "level3")) + await fs.writeFile(path.join(tempDir, "level1", "level2", "level3", "deep.ts"), "") + const ctx = createMockContext(tempDir) + + const result = await tool.execute({ depth: 2 }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetStructureResult + expect(data.content).toContain("level1") + expect(data.content).toContain("level2") + expect(data.content).not.toContain("level3") + expect(data.content).not.toContain("deep.ts") + }) + + it("should filter subdirectory when path specified", async () => { + await fs.mkdir(path.join(tempDir, "src")) + await fs.mkdir(path.join(tempDir, "tests")) + await fs.writeFile(path.join(tempDir, "src", "index.ts"), "") + await fs.writeFile(path.join(tempDir, "tests", "test.ts"), "") + const ctx = createMockContext(tempDir) + + const result = await tool.execute({ path: "src" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetStructureResult + expect(data.path).toBe("src") + expect(data.content).toContain("index.ts") + expect(data.content).not.toContain("test.ts") + }) + + it("should ignore node_modules", async () => { + await fs.mkdir(path.join(tempDir, "node_modules")) + await fs.writeFile(path.join(tempDir, "node_modules", "pkg.js"), "") + await fs.writeFile(path.join(tempDir, "index.ts"), "") + const ctx = createMockContext(tempDir) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetStructureResult + expect(data.content).not.toContain("node_modules") + expect(data.content).toContain("index.ts") + }) + + it("should ignore .git directory", async () => { + await fs.mkdir(path.join(tempDir, ".git")) + await fs.writeFile(path.join(tempDir, ".git", "config"), "") + await fs.writeFile(path.join(tempDir, "index.ts"), "") + const ctx = createMockContext(tempDir) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetStructureResult + expect(data.content).not.toContain(".git") + }) + + it("should sort directories before files", async () => { + await fs.writeFile(path.join(tempDir, "aaa.ts"), "") + await fs.mkdir(path.join(tempDir, "zzz")) + const ctx = createMockContext(tempDir) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetStructureResult + const zzzIndex = data.content.indexOf("zzz") + const aaaIndex = data.content.indexOf("aaa.ts") + expect(zzzIndex).toBeLessThan(aaaIndex) + }) + + it("should return error for path outside project root", async () => { + const ctx = createMockContext(tempDir) + + const result = await tool.execute({ path: "../outside" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("Path must be within project root") + }) + + it("should return error for non-directory path", async () => { + await fs.writeFile(path.join(tempDir, "file.ts"), "") + const ctx = createMockContext(tempDir) + + const result = await tool.execute({ path: "file.ts" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toContain("is not a directory") + }) + + it("should return error for non-existent path", async () => { + const ctx = createMockContext(tempDir) + + const result = await tool.execute({ path: "nonexistent" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toContain("ENOENT") + }) + + it("should include callId in result", async () => { + const ctx = createMockContext(tempDir) + + const result = await tool.execute({}, ctx) + + expect(result.callId).toMatch(/^get_structure-\d+$/) + }) + + it("should use tree icons in output", async () => { + await fs.mkdir(path.join(tempDir, "src")) + await fs.writeFile(path.join(tempDir, "index.ts"), "") + const ctx = createMockContext(tempDir) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetStructureResult + expect(data.content).toContain("📁") + expect(data.content).toContain("📄") + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/registry.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/registry.test.ts new file mode 100644 index 0000000..ae3aab2 --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/registry.test.ts @@ -0,0 +1,449 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { ToolRegistry } from "../../../../src/infrastructure/tools/registry.js" +import type { + ITool, + ToolContext, + ToolParameterSchema, +} from "../../../../src/domain/services/ITool.js" +import type { ToolResult } from "../../../../src/domain/value-objects/ToolResult.js" +import { IpuaroError } from "../../../../src/shared/errors/IpuaroError.js" + +/** + * Creates a mock tool for testing. + */ +function createMockTool(overrides: Partial = {}): ITool { + return { + name: "mock_tool", + description: "A mock tool for testing", + parameters: [ + { + name: "path", + type: "string", + description: "File path", + required: true, + }, + { + name: "optional", + type: "number", + description: "Optional param", + required: false, + }, + ], + requiresConfirmation: false, + category: "read", + execute: vi.fn().mockResolvedValue({ + callId: "test-123", + success: true, + data: { result: "success" }, + executionTimeMs: 10, + }), + validateParams: vi.fn().mockReturnValue(null), + ...overrides, + } +} + +/** + * Creates a mock tool context for testing. + */ +function createMockContext(overrides: Partial = {}): ToolContext { + return { + projectRoot: "/test/project", + storage: {} as ToolContext["storage"], + requestConfirmation: vi.fn().mockResolvedValue(true), + onProgress: vi.fn(), + ...overrides, + } +} + +describe("ToolRegistry", () => { + let registry: ToolRegistry + + beforeEach(() => { + registry = new ToolRegistry() + }) + + describe("register", () => { + it("should register a tool", () => { + const tool = createMockTool() + + registry.register(tool) + + expect(registry.has("mock_tool")).toBe(true) + expect(registry.size).toBe(1) + }) + + it("should register multiple tools", () => { + const tool1 = createMockTool({ name: "tool_1" }) + const tool2 = createMockTool({ name: "tool_2" }) + + registry.register(tool1) + registry.register(tool2) + + expect(registry.size).toBe(2) + expect(registry.has("tool_1")).toBe(true) + expect(registry.has("tool_2")).toBe(true) + }) + + it("should throw error when registering duplicate tool name", () => { + const tool1 = createMockTool({ name: "duplicate" }) + const tool2 = createMockTool({ name: "duplicate" }) + + registry.register(tool1) + + expect(() => registry.register(tool2)).toThrow(IpuaroError) + expect(() => registry.register(tool2)).toThrow('Tool "duplicate" is already registered') + }) + }) + + describe("unregister", () => { + it("should remove a registered tool", () => { + const tool = createMockTool() + registry.register(tool) + + const result = registry.unregister("mock_tool") + + expect(result).toBe(true) + expect(registry.has("mock_tool")).toBe(false) + expect(registry.size).toBe(0) + }) + + it("should return false when tool not found", () => { + const result = registry.unregister("nonexistent") + + expect(result).toBe(false) + }) + }) + + describe("get", () => { + it("should return registered tool", () => { + const tool = createMockTool() + registry.register(tool) + + const result = registry.get("mock_tool") + + expect(result).toBe(tool) + }) + + it("should return undefined for unknown tool", () => { + const result = registry.get("unknown") + + expect(result).toBeUndefined() + }) + }) + + describe("getAll", () => { + it("should return empty array when no tools registered", () => { + const result = registry.getAll() + + expect(result).toEqual([]) + }) + + it("should return all registered tools", () => { + const tool1 = createMockTool({ name: "tool_1" }) + const tool2 = createMockTool({ name: "tool_2" }) + registry.register(tool1) + registry.register(tool2) + + const result = registry.getAll() + + expect(result).toHaveLength(2) + expect(result).toContain(tool1) + expect(result).toContain(tool2) + }) + }) + + describe("getByCategory", () => { + it("should return tools by category", () => { + const readTool = createMockTool({ name: "read_tool", category: "read" }) + const editTool = createMockTool({ name: "edit_tool", category: "edit" }) + const gitTool = createMockTool({ name: "git_tool", category: "git" }) + registry.register(readTool) + registry.register(editTool) + registry.register(gitTool) + + const readTools = registry.getByCategory("read") + const editTools = registry.getByCategory("edit") + + expect(readTools).toHaveLength(1) + expect(readTools[0]).toBe(readTool) + expect(editTools).toHaveLength(1) + expect(editTools[0]).toBe(editTool) + }) + + it("should return empty array for category with no tools", () => { + const readTool = createMockTool({ category: "read" }) + registry.register(readTool) + + const result = registry.getByCategory("analysis") + + expect(result).toEqual([]) + }) + }) + + describe("has", () => { + it("should return true for registered tool", () => { + registry.register(createMockTool()) + + expect(registry.has("mock_tool")).toBe(true) + }) + + it("should return false for unknown tool", () => { + expect(registry.has("unknown")).toBe(false) + }) + }) + + describe("execute", () => { + it("should execute tool and return result", async () => { + const tool = createMockTool() + registry.register(tool) + const ctx = createMockContext() + + const result = await registry.execute("mock_tool", { path: "test.ts" }, ctx) + + expect(result.success).toBe(true) + expect(result.data).toEqual({ result: "success" }) + expect(tool.execute).toHaveBeenCalledWith({ path: "test.ts" }, ctx) + }) + + it("should return error result for unknown tool", async () => { + const ctx = createMockContext() + + const result = await registry.execute("unknown", {}, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe('Tool "unknown" not found') + }) + + it("should return error result when validation fails", async () => { + const tool = createMockTool({ + validateParams: vi.fn().mockReturnValue("Missing required param: path"), + }) + registry.register(tool) + const ctx = createMockContext() + + const result = await registry.execute("mock_tool", {}, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("Missing required param: path") + expect(tool.execute).not.toHaveBeenCalled() + }) + + it("should request confirmation for tools that require it", async () => { + const tool = createMockTool({ requiresConfirmation: true }) + registry.register(tool) + const ctx = createMockContext() + + await registry.execute("mock_tool", { path: "test.ts" }, ctx) + + expect(ctx.requestConfirmation).toHaveBeenCalled() + expect(tool.execute).toHaveBeenCalled() + }) + + it("should not execute when confirmation is denied", async () => { + const tool = createMockTool({ requiresConfirmation: true }) + registry.register(tool) + const ctx = createMockContext({ + requestConfirmation: vi.fn().mockResolvedValue(false), + }) + + const result = await registry.execute("mock_tool", { path: "test.ts" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("User cancelled operation") + expect(tool.execute).not.toHaveBeenCalled() + }) + + it("should not request confirmation for safe tools", async () => { + const tool = createMockTool({ requiresConfirmation: false }) + registry.register(tool) + const ctx = createMockContext() + + await registry.execute("mock_tool", { path: "test.ts" }, ctx) + + expect(ctx.requestConfirmation).not.toHaveBeenCalled() + expect(tool.execute).toHaveBeenCalled() + }) + + it("should catch and return errors from tool execution", async () => { + const tool = createMockTool({ + execute: vi.fn().mockRejectedValue(new Error("Execution failed")), + }) + registry.register(tool) + const ctx = createMockContext() + + const result = await registry.execute("mock_tool", { path: "test.ts" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("Execution failed") + }) + + it("should include callId in result", async () => { + const tool = createMockTool() + registry.register(tool) + const ctx = createMockContext() + + const result = await registry.execute("mock_tool", { path: "test.ts" }, ctx) + + expect(result.callId).toMatch(/^mock_tool-\d+$/) + }) + }) + + describe("getToolDefinitions", () => { + it("should return empty array when no tools registered", () => { + const result = registry.getToolDefinitions() + + expect(result).toEqual([]) + }) + + it("should convert tools to LLM-compatible format", () => { + const tool = createMockTool() + registry.register(tool) + + const result = registry.getToolDefinitions() + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + name: "mock_tool", + description: "A mock tool for testing", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "File path", + }, + optional: { + type: "number", + description: "Optional param", + }, + }, + required: ["path"], + }, + }) + }) + + it("should handle tools with no parameters", () => { + const tool = createMockTool({ parameters: [] }) + registry.register(tool) + + const result = registry.getToolDefinitions() + + expect(result[0].parameters).toEqual({ + type: "object", + properties: {}, + required: [], + }) + }) + + it("should handle multiple tools", () => { + registry.register(createMockTool({ name: "tool_1" })) + registry.register(createMockTool({ name: "tool_2" })) + + const result = registry.getToolDefinitions() + + expect(result).toHaveLength(2) + expect(result.map((t) => t.name)).toEqual(["tool_1", "tool_2"]) + }) + }) + + describe("clear", () => { + it("should remove all tools", () => { + registry.register(createMockTool({ name: "tool_1" })) + registry.register(createMockTool({ name: "tool_2" })) + + registry.clear() + + expect(registry.size).toBe(0) + expect(registry.getAll()).toEqual([]) + }) + }) + + describe("getNames", () => { + it("should return all tool names", () => { + registry.register(createMockTool({ name: "alpha" })) + registry.register(createMockTool({ name: "beta" })) + + const result = registry.getNames() + + expect(result).toEqual(["alpha", "beta"]) + }) + + it("should return empty array when no tools", () => { + const result = registry.getNames() + + expect(result).toEqual([]) + }) + }) + + describe("getConfirmationTools", () => { + it("should return only tools requiring confirmation", () => { + registry.register(createMockTool({ name: "safe", requiresConfirmation: false })) + registry.register(createMockTool({ name: "dangerous", requiresConfirmation: true })) + registry.register(createMockTool({ name: "also_safe", requiresConfirmation: false })) + + const result = registry.getConfirmationTools() + + expect(result).toHaveLength(1) + expect(result[0].name).toBe("dangerous") + }) + }) + + describe("getSafeTools", () => { + it("should return only tools not requiring confirmation", () => { + registry.register(createMockTool({ name: "safe", requiresConfirmation: false })) + registry.register(createMockTool({ name: "dangerous", requiresConfirmation: true })) + registry.register(createMockTool({ name: "also_safe", requiresConfirmation: false })) + + const result = registry.getSafeTools() + + expect(result).toHaveLength(2) + expect(result.map((t) => t.name)).toEqual(["safe", "also_safe"]) + }) + }) + + describe("size", () => { + it("should return 0 for empty registry", () => { + expect(registry.size).toBe(0) + }) + + it("should return correct count", () => { + registry.register(createMockTool({ name: "a" })) + registry.register(createMockTool({ name: "b" })) + registry.register(createMockTool({ name: "c" })) + + expect(registry.size).toBe(3) + }) + }) + + describe("integration scenarios", () => { + it("should handle full workflow: register, execute, unregister", async () => { + const tool = createMockTool() + const ctx = createMockContext() + + registry.register(tool) + expect(registry.has("mock_tool")).toBe(true) + + const result = await registry.execute("mock_tool", { path: "test.ts" }, ctx) + expect(result.success).toBe(true) + + registry.unregister("mock_tool") + expect(registry.has("mock_tool")).toBe(false) + + const afterUnregister = await registry.execute("mock_tool", {}, ctx) + expect(afterUnregister.success).toBe(false) + }) + + it("should maintain isolation between registrations", () => { + const registry1 = new ToolRegistry() + const registry2 = new ToolRegistry() + + registry1.register(createMockTool({ name: "tool_1" })) + registry2.register(createMockTool({ name: "tool_2" })) + + expect(registry1.has("tool_1")).toBe(true) + expect(registry1.has("tool_2")).toBe(false) + expect(registry2.has("tool_1")).toBe(false) + expect(registry2.has("tool_2")).toBe(true) + }) + }) +})