summaryrefslogtreecommitdiff
path: root/packages/server/src/utils
diff options
context:
space:
mode:
Diffstat (limited to 'packages/server/src/utils')
-rw-r--r--packages/server/src/utils/errors.ts24
-rw-r--r--packages/server/src/utils/getFolderStructure.ts389
-rw-r--r--packages/server/src/utils/paths.ts102
-rw-r--r--packages/server/src/utils/schemaValidator.ts59
4 files changed, 574 insertions, 0 deletions
diff --git a/packages/server/src/utils/errors.ts b/packages/server/src/utils/errors.ts
new file mode 100644
index 00000000..15417c83
--- /dev/null
+++ b/packages/server/src/utils/errors.ts
@@ -0,0 +1,24 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export function isNodeError(error: unknown): error is NodeJS.ErrnoException {
+ return error instanceof Error && 'code' in error;
+}
+
+export function getErrorMessage(error: unknown): string {
+ if (error instanceof Error) {
+ return error.message;
+ } else {
+ // Attempt to convert the non-Error value to a string for logging
+ try {
+ const errorMessage = String(error);
+ return errorMessage;
+ } catch {
+ // If String() itself fails (highly unlikely)
+ return 'Failed to get error details';
+ }
+ }
+}
diff --git a/packages/server/src/utils/getFolderStructure.ts b/packages/server/src/utils/getFolderStructure.ts
new file mode 100644
index 00000000..0caabe0f
--- /dev/null
+++ b/packages/server/src/utils/getFolderStructure.ts
@@ -0,0 +1,389 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as fs from 'fs/promises';
+import * as path from 'path';
+import { getErrorMessage, isNodeError } from './errors.js';
+
+const MAX_ITEMS = 200;
+const TRUNCATION_INDICATOR = '...';
+const DEFAULT_IGNORED_FOLDERS = new Set(['node_modules', '.git', 'dist']);
+
+// --- Interfaces ---
+
+/** Options for customizing folder structure retrieval. */
+interface FolderStructureOptions {
+ /** Maximum number of files and folders combined to display. Defaults to 200. */
+ maxItems?: number;
+ /** Set of folder names to ignore completely. Case-sensitive. */
+ ignoredFolders?: Set<string>;
+ /** Optional regex to filter included files by name. */
+ fileIncludePattern?: RegExp;
+}
+
+// Define a type for the merged options where fileIncludePattern remains optional
+type MergedFolderStructureOptions = Required<
+ Omit<FolderStructureOptions, 'fileIncludePattern'>
+> & {
+ fileIncludePattern?: RegExp;
+};
+
+/** Represents the full, unfiltered information about a folder and its contents. */
+interface FullFolderInfo {
+ name: string;
+ path: string;
+ files: string[];
+ subFolders: FullFolderInfo[];
+ totalChildren: number; // Total files + subfolders recursively
+ totalFiles: number; // Total files recursively
+ isIgnored?: boolean; // Flag to easily identify ignored folders later
+}
+
+/** Represents the potentially truncated structure used for display. */
+interface ReducedFolderNode {
+ name: string; // Folder name
+ isRoot?: boolean;
+ files: string[]; // File names, might end with '...'
+ subFolders: ReducedFolderNode[]; // Subfolders, might be truncated
+ hasMoreFiles?: boolean; // Indicates if files were truncated for this specific folder
+ hasMoreSubfolders?: boolean; // Indicates if subfolders were truncated for this specific folder
+}
+
+// --- Helper Functions ---
+
+/**
+ * Recursively reads the full directory structure without truncation.
+ * Ignored folders are included but not recursed into.
+ * @param folderPath The absolute path to the folder.
+ * @param options Configuration options.
+ * @returns A promise resolving to the FullFolderInfo or null if access denied/not found.
+ */
+async function readFullStructure(
+ folderPath: string,
+ options: MergedFolderStructureOptions,
+): Promise<FullFolderInfo | null> {
+ const name = path.basename(folderPath);
+ // Initialize with isIgnored: false
+ const folderInfo: Omit<FullFolderInfo, 'totalChildren' | 'totalFiles'> = {
+ name,
+ path: folderPath,
+ files: [],
+ subFolders: [],
+ isIgnored: false,
+ };
+
+ let totalChildrenCount = 0;
+ let totalFileCount = 0;
+
+ try {
+ const entries = await fs.readdir(folderPath, { withFileTypes: true });
+
+ // Process directories first
+ for (const entry of entries) {
+ if (entry.isDirectory()) {
+ const subFolderName = entry.name;
+ const subFolderPath = path.join(folderPath, subFolderName);
+
+ // Check if the folder should be ignored
+ if (options.ignoredFolders.has(subFolderName)) {
+ // Add ignored folder node but don't recurse
+ const ignoredFolderInfo: FullFolderInfo = {
+ name: subFolderName,
+ path: subFolderPath,
+ files: [],
+ subFolders: [],
+ totalChildren: 0, // No children explored
+ totalFiles: 0, // No files explored
+ isIgnored: true, // Mark as ignored
+ };
+ folderInfo.subFolders.push(ignoredFolderInfo);
+ // Skip recursion for this folder
+ continue;
+ }
+
+ // If not ignored, recurse as before
+ const subFolderInfo = await readFullStructure(subFolderPath, options);
+ // Add non-empty folders OR explicitly ignored folders
+ if (
+ subFolderInfo &&
+ (subFolderInfo.totalChildren > 0 ||
+ subFolderInfo.files.length > 0 ||
+ subFolderInfo.isIgnored)
+ ) {
+ folderInfo.subFolders.push(subFolderInfo);
+ }
+ }
+ }
+
+ // Then process files (only if the current folder itself isn't marked as ignored)
+ for (const entry of entries) {
+ if (entry.isFile()) {
+ const fileName = entry.name;
+ // Include if no pattern or if pattern matches
+ if (
+ !options.fileIncludePattern ||
+ options.fileIncludePattern.test(fileName)
+ ) {
+ folderInfo.files.push(fileName);
+ }
+ }
+ }
+
+ // Calculate totals *after* processing children
+ // Ignored folders contribute 0 to counts here because we didn't look inside.
+ totalFileCount =
+ folderInfo.files.length +
+ folderInfo.subFolders.reduce((sum, sf) => sum + sf.totalFiles, 0);
+ // Count the ignored folder itself as one child item in the parent's count.
+ totalChildrenCount =
+ folderInfo.files.length +
+ folderInfo.subFolders.length +
+ folderInfo.subFolders.reduce((sum, sf) => sum + sf.totalChildren, 0);
+ } catch (error: unknown) {
+ if (
+ isNodeError(error) &&
+ (error.code === 'EACCES' || error.code === 'ENOENT')
+ ) {
+ console.warn(
+ `Warning: Could not read directory ${folderPath}: ${error.message}`,
+ );
+ return null;
+ }
+ throw error;
+ }
+
+ return {
+ ...(folderInfo as FullFolderInfo), // Cast needed after conditional assignment check
+ totalChildren: totalChildrenCount,
+ totalFiles: totalFileCount,
+ };
+}
+
+/**
+ * Reduces the full folder structure based on the maxItems limit using BFS.
+ * Handles explicitly ignored folders by showing them with a truncation indicator.
+ * @param fullInfo The complete folder structure info.
+ * @param maxItems The maximum number of items (files + folders) to include.
+ * @param ignoredFolders The set of folder names that were ignored during the read phase.
+ * @returns The root node of the reduced structure.
+ */
+function reduceStructure(
+ fullInfo: FullFolderInfo,
+ maxItems: number,
+): ReducedFolderNode {
+ const rootReducedNode: ReducedFolderNode = {
+ name: fullInfo.name,
+ files: [],
+ subFolders: [],
+ isRoot: true,
+ };
+ const queue: Array<{
+ original: FullFolderInfo;
+ reduced: ReducedFolderNode;
+ }> = [];
+
+ // Don't count the root itself towards the limit initially
+ queue.push({ original: fullInfo, reduced: rootReducedNode });
+ let itemCount = 0; // Count folders + files added to the reduced structure
+
+ while (queue.length > 0) {
+ const { original: originalFolder, reduced: reducedFolder } = queue.shift()!;
+
+ // If the folder being processed was itself marked as ignored (shouldn't happen for root)
+ if (originalFolder.isIgnored) {
+ continue;
+ }
+
+ // Process Files
+ let fileLimitReached = false;
+ for (const file of originalFolder.files) {
+ // Check limit *before* adding the file
+ if (itemCount >= maxItems) {
+ if (!fileLimitReached) {
+ reducedFolder.files.push(TRUNCATION_INDICATOR);
+ reducedFolder.hasMoreFiles = true;
+ fileLimitReached = true;
+ }
+ break;
+ }
+ reducedFolder.files.push(file);
+ itemCount++;
+ }
+
+ // Process Subfolders
+ let subfolderLimitReached = false;
+ for (const subFolder of originalFolder.subFolders) {
+ // Count the folder itself towards the limit
+ itemCount++;
+ if (itemCount > maxItems) {
+ if (!subfolderLimitReached) {
+ // Add a placeholder node ONLY if we haven't already added one
+ const truncatedSubfolderNode: ReducedFolderNode = {
+ name: subFolder.name,
+ files: [TRUNCATION_INDICATOR], // Generic truncation
+ subFolders: [],
+ hasMoreFiles: true,
+ };
+ reducedFolder.subFolders.push(truncatedSubfolderNode);
+ reducedFolder.hasMoreSubfolders = true;
+ subfolderLimitReached = true;
+ }
+ continue; // Stop processing further subfolders for this parent
+ }
+
+ // Handle explicitly ignored folders identified during the read phase
+ if (subFolder.isIgnored) {
+ const ignoredReducedNode: ReducedFolderNode = {
+ name: subFolder.name,
+ files: [TRUNCATION_INDICATOR], // Indicate contents ignored/truncated
+ subFolders: [],
+ hasMoreFiles: true, // Mark as truncated
+ };
+ reducedFolder.subFolders.push(ignoredReducedNode);
+ // DO NOT add the ignored folder to the queue for further processing
+ } else {
+ // If not ignored and within limit, create the reduced node and add to queue
+ const reducedSubFolder: ReducedFolderNode = {
+ name: subFolder.name,
+ files: [],
+ subFolders: [],
+ };
+ reducedFolder.subFolders.push(reducedSubFolder);
+ queue.push({ original: subFolder, reduced: reducedSubFolder });
+ }
+ }
+ }
+
+ return rootReducedNode;
+}
+
+/** Calculates the total number of items present in the reduced structure. */
+function countReducedItems(node: ReducedFolderNode): number {
+ let count = 0;
+ // Count files, treating '...' as one item if present
+ count += node.files.length;
+
+ // Count subfolders and recursively count their contents
+ count += node.subFolders.length;
+ for (const sub of node.subFolders) {
+ // Check if it's a placeholder ignored/truncated node
+ const isTruncatedPlaceholder =
+ sub.files.length === 1 &&
+ sub.files[0] === TRUNCATION_INDICATOR &&
+ sub.subFolders.length === 0;
+
+ if (!isTruncatedPlaceholder) {
+ count += countReducedItems(sub);
+ }
+ // Don't add count for items *inside* the placeholder node itself.
+ }
+ return count;
+}
+
+/**
+ * Formats the reduced folder structure into a tree-like string.
+ * (No changes needed in this function)
+ * @param node The current node in the reduced structure.
+ * @param indent The current indentation string.
+ * @param isLast Sibling indicator.
+ * @param builder Array to build the string lines.
+ */
+function formatReducedStructure(
+ node: ReducedFolderNode,
+ indent: string,
+ isLast: boolean,
+ builder: string[],
+): void {
+ const connector = isLast ? '└───' : '├───';
+ const linePrefix = indent + connector;
+
+ // Don't print the root node's name directly, only its contents
+ if (!node.isRoot) {
+ builder.push(`${linePrefix}${node.name}/`);
+ }
+
+ const childIndent = indent + (isLast || node.isRoot ? ' ' : '│ '); // Use " " if last, "│" otherwise
+
+ // Render files
+ const fileCount = node.files.length;
+ for (let i = 0; i < fileCount; i++) {
+ const isLastFile = i === fileCount - 1 && node.subFolders.length === 0;
+ const fileConnector = isLastFile ? '└───' : '├───';
+ builder.push(`${childIndent}${fileConnector}${node.files[i]}`);
+ }
+
+ // Render subfolders
+ const subFolderCount = node.subFolders.length;
+ for (let i = 0; i < subFolderCount; i++) {
+ const isLastSub = i === subFolderCount - 1;
+ formatReducedStructure(node.subFolders[i], childIndent, isLastSub, builder);
+ }
+}
+
+// --- Main Exported Function ---
+
+/**
+ * Generates a string representation of a directory's structure,
+ * limiting the number of items displayed. Ignored folders are shown
+ * followed by '...' instead of their contents.
+ *
+ * @param directory The absolute or relative path to the directory.
+ * @param options Optional configuration settings.
+ * @returns A promise resolving to the formatted folder structure string.
+ */
+export async function getFolderStructure(
+ directory: string,
+ options?: FolderStructureOptions,
+): Promise<string> {
+ const resolvedPath = path.resolve(directory);
+ const mergedOptions: MergedFolderStructureOptions = {
+ maxItems: options?.maxItems ?? MAX_ITEMS,
+ ignoredFolders: options?.ignoredFolders ?? DEFAULT_IGNORED_FOLDERS,
+ fileIncludePattern: options?.fileIncludePattern,
+ };
+
+ try {
+ // 1. Read the full structure (includes ignored folders marked as such)
+ const fullInfo = await readFullStructure(resolvedPath, mergedOptions);
+
+ if (!fullInfo) {
+ return `Error: Could not read directory "${resolvedPath}". Check path and permissions.`;
+ }
+
+ // 2. Reduce the structure (handles ignored folders specifically)
+ const reducedRoot = reduceStructure(fullInfo, mergedOptions.maxItems);
+
+ // 3. Count items in the *reduced* structure for the summary
+ const rootNodeItselfCount = 0; // Don't count the root node in the items summary
+ const reducedItemCount =
+ countReducedItems(reducedRoot) - rootNodeItselfCount;
+
+ // 4. Format the reduced structure into a string
+ const structureLines: string[] = [];
+ formatReducedStructure(reducedRoot, '', true, structureLines);
+
+ // 5. Build the final output string
+ const displayPath = resolvedPath.replace(/\\/g, '/');
+ const totalOriginalChildren = fullInfo.totalChildren;
+
+ let disclaimer = '';
+ // Check if any truncation happened OR if ignored folders were present
+ if (
+ reducedItemCount < totalOriginalChildren ||
+ fullInfo.subFolders.some((sf) => sf.isIgnored)
+ ) {
+ disclaimer = `Folders or files indicated with ${TRUNCATION_INDICATOR} contain more items not shown or were ignored.`;
+ }
+
+ const summary =
+ `Showing ${reducedItemCount} of ${totalOriginalChildren} items (files + folders). ${disclaimer}`.trim();
+
+ return `${summary}\n\n${displayPath}/\n${structureLines.join('\n')}`;
+ } catch (error: unknown) {
+ console.error(`Error getting folder structure for ${resolvedPath}:`, error);
+ return `Error processing directory "${resolvedPath}": ${getErrorMessage(error)}`;
+ }
+}
diff --git a/packages/server/src/utils/paths.ts b/packages/server/src/utils/paths.ts
new file mode 100644
index 00000000..f1a42131
--- /dev/null
+++ b/packages/server/src/utils/paths.ts
@@ -0,0 +1,102 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import path from 'node:path'; // Import the 'path' module
+
+/**
+ * Shortens a path string if it exceeds maxLen, prioritizing the start and end segments.
+ * Example: /path/to/a/very/long/file.txt -> /path/.../long/file.txt
+ */
+export function shortenPath(filePath: string, maxLen: number = 35): string {
+ if (filePath.length <= maxLen) {
+ return filePath;
+ }
+
+ const parsedPath = path.parse(filePath);
+ const root = parsedPath.root;
+ const separator = path.sep;
+
+ // Get segments of the path *after* the root
+ const relativePath = filePath.substring(root.length);
+ const segments = relativePath.split(separator).filter((s) => s !== ''); // Filter out empty segments
+
+ // Handle cases with no segments after root (e.g., "/", "C:\") or only one segment
+ if (segments.length <= 1) {
+ // Fallback to simple start/end truncation for very short paths or single segments
+ const keepLen = Math.floor((maxLen - 3) / 2);
+ // Ensure keepLen is not negative if maxLen is very small
+ if (keepLen <= 0) {
+ return filePath.substring(0, maxLen - 3) + '...';
+ }
+ const start = filePath.substring(0, keepLen);
+ const end = filePath.substring(filePath.length - keepLen);
+ return `${start}...${end}`;
+ }
+
+ const firstDir = segments[0];
+ const startComponent = root + firstDir;
+
+ const endPartSegments: string[] = [];
+ // Base length: startComponent + separator + "..."
+ let currentLength = startComponent.length + separator.length + 3;
+
+ // Iterate backwards through segments (excluding the first one)
+ for (let i = segments.length - 1; i >= 1; i--) {
+ const segment = segments[i];
+ // Length needed if we add this segment: current + separator + segment
+ const lengthWithSegment = currentLength + separator.length + segment.length;
+
+ if (lengthWithSegment <= maxLen) {
+ endPartSegments.unshift(segment); // Add to the beginning of the end part
+ currentLength = lengthWithSegment;
+ } else {
+ // Adding this segment would exceed maxLen
+ break;
+ }
+ }
+
+ // Construct the final path
+ let result = startComponent + separator + '...';
+ if (endPartSegments.length > 0) {
+ result += separator + endPartSegments.join(separator);
+ }
+
+ // As a final check, if the result is somehow still too long (e.g., startComponent + ... is too long)
+ // fallback to simple truncation of the original path
+ if (result.length > maxLen) {
+ const keepLen = Math.floor((maxLen - 3) / 2);
+ if (keepLen <= 0) {
+ return filePath.substring(0, maxLen - 3) + '...';
+ }
+ const start = filePath.substring(0, keepLen);
+ const end = filePath.substring(filePath.length - keepLen);
+ return `${start}...${end}`;
+ }
+
+ return result;
+}
+
+/**
+ * Calculates the relative path from a root directory to a target path.
+ * Ensures both paths are resolved before calculating.
+ * Returns '.' if the target path is the same as the root directory.
+ *
+ * @param targetPath The absolute or relative path to make relative.
+ * @param rootDirectory The absolute path of the directory to make the target path relative to.
+ * @returns The relative path from rootDirectory to targetPath.
+ */
+export function makeRelative(
+ targetPath: string,
+ rootDirectory: string,
+): string {
+ const resolvedTargetPath = path.resolve(targetPath);
+ const resolvedRootDirectory = path.resolve(rootDirectory);
+
+ const relativePath = path.relative(resolvedRootDirectory, resolvedTargetPath);
+
+ // If the paths are the same, path.relative returns '', return '.' instead
+ return relativePath || '.';
+}
diff --git a/packages/server/src/utils/schemaValidator.ts b/packages/server/src/utils/schemaValidator.ts
new file mode 100644
index 00000000..107ccc85
--- /dev/null
+++ b/packages/server/src/utils/schemaValidator.ts
@@ -0,0 +1,59 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Simple utility to validate objects against JSON Schemas
+ * In a real implementation, you would use a library like Ajv
+ */
+export class SchemaValidator {
+ /**
+ * Validates data against a JSON schema
+ * @param schema JSON Schema to validate against
+ * @param data Data to validate
+ * @returns True if valid, false otherwise
+ */
+ static validate(schema: Record<string, unknown>, data: unknown): boolean {
+ // This is a simplified implementation
+ // In a real application, you would use a library like Ajv for proper validation
+
+ // Check for required fields
+ if (schema.required && Array.isArray(schema.required)) {
+ const required = schema.required as string[];
+ const dataObj = data as Record<string, unknown>;
+
+ for (const field of required) {
+ if (dataObj[field] === undefined) {
+ console.error(`Missing required field: ${field}`);
+ return false;
+ }
+ }
+ }
+
+ // Check property types if properties are defined
+ if (schema.properties && typeof schema.properties === 'object') {
+ const properties = schema.properties as Record<string, { type?: string }>;
+ const dataObj = data as Record<string, unknown>;
+
+ for (const [key, prop] of Object.entries(properties)) {
+ if (dataObj[key] !== undefined && prop.type) {
+ const expectedType = prop.type;
+ const actualType = Array.isArray(dataObj[key])
+ ? 'array'
+ : typeof dataObj[key];
+
+ if (expectedType !== actualType) {
+ console.error(
+ `Type mismatch for property "${key}": expected ${expectedType}, got ${actualType}`,
+ );
+ return false;
+ }
+ }
+ }
+ }
+
+ return true;
+ }
+}