summaryrefslogtreecommitdiff
path: root/packages/cli/src/utils/getFolderStructure.ts
blob: 5c26f400d292958a16ca22a08c5b9386b483e1c0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
import * as fs from 'fs/promises';
import * as path from 'path';

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: 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: any) {
    if (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,
  ignoredFolders: Set<string>, // Pass ignoredFolders for checking
): 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,
      mergedOptions.ignoredFolders,
    );

    // 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: any) {
    console.error(`Error getting folder structure for ${resolvedPath}:`, error);
    return `Error processing directory "${resolvedPath}": ${error.message}`;
  }
}