diff --git a/src/vfs/VirtualFileSystem.ts b/src/vfs/VirtualFileSystem.ts index 0a000396..90018863 100644 --- a/src/vfs/VirtualFileSystem.ts +++ b/src/vfs/VirtualFileSystem.ts @@ -1274,7 +1274,20 @@ export class VirtualFileSystem implements IVirtualFileSystem { } /** - * Read directory contents + * @description List a directory's contents. Non-recursive (default) + * returns direct children only, named by basename. `recursive: true` + * lists every descendant at any depth (files and directories), each + * reported as a path RELATIVE TO THE QUERIED DIRECTORY — matching Node's + * `fs.readdir(dir, { recursive: true })` convention — e.g. `'sub'` and + * `'sub/file.txt'` for a nested file. With `withFileTypes: true`, each + * {@link VFSDirent}'s `name` carries that same value (relative when + * recursive, basename otherwise); `path` is always the absolute VFS path + * either way. + * @param path - The directory to list. + * @param options - `recursive`, `withFileTypes`, `filter`, `sort`, + * `offset`/`limit` (pagination applies AFTER filter/sort, over the full + * recursive set when `recursive: true`). + * @throws {VFSError} ENOTDIR when `path` is not a directory. */ async readdir(path: string, options?: ReaddirOptions): Promise { await this.ensureInitialized() @@ -1287,8 +1300,12 @@ export class VirtualFileSystem implements IVirtualFileSystem { throw new VFSError(VFSErrorCode.ENOTDIR, `Not a directory: ${path}`, path, 'readdir') } - // Get children - let children = await this.pathResolver.getChildren(entityId) + // Direct children, or every descendant at any depth. gatherDescendants() + // is the same graph-traversal + ONE-batch-fetch path getTreeStructure()/ + // getDescendants() already use — no per-directory storage round trips. + let children = options?.recursive + ? await this.gatherDescendants(entityId, Infinity) + : await this.pathResolver.getChildren(entityId) // Apply filters if (options?.filter) { @@ -1312,17 +1329,29 @@ export class VirtualFileSystem implements IVirtualFileSystem { // Directory access time updates caused 50-100ms GCS write on EVERY readdir // await this.updateAccessTime(entityId) // ← REMOVED + // The queried directory's own canonical (already-normalized) path — the + // base every recursive entry's relative name is computed against. Using + // the resolved entity's OWN path (rather than the raw `path` argument) + // means no separate normalization step is needed here. + const baseDir = entity.metadata.path + const relativeToBase = (childPath: string): string => { + const prefix = baseDir === '/' ? '/' : `${baseDir}/` + return childPath.startsWith(prefix) ? childPath.slice(prefix.length) : childPath + } + // Return appropriate format if (options?.withFileTypes) { return children.map(child => ({ - name: child.metadata.name, + name: options?.recursive ? relativeToBase(child.metadata.path) : child.metadata.name, path: child.metadata.path, type: child.metadata.vfsType, entityId: child.id } as VFSDirent)) } - return children.map(child => child.metadata.name) + return children.map(child => + options?.recursive ? relativeToBase(child.metadata.path) : child.metadata.name + ) } // ============= Metadata Operations ============= diff --git a/src/vfs/types.ts b/src/vfs/types.ts index 9188476b..17687dd3 100644 --- a/src/vfs/types.ts +++ b/src/vfs/types.ts @@ -133,8 +133,17 @@ export interface VFSStats { * Directory entry (for readdir) */ export interface VFSDirent { + /** + * The entry's basename (e.g. `'file.txt'`) when `readdir()` was called + * WITHOUT `recursive: true`. When `recursive: true` was set, this is + * instead the entry's path RELATIVE TO THE QUERIED DIRECTORY (e.g. + * `'sub/file.txt'` for a nested file) — the same value that would appear + * in the plain string-array form of a recursive `readdir()` call. `path` + * below always carries the absolute VFS path regardless of `recursive`, + * so nothing is lost either way. + */ name: string - path: string // Full path + path: string // Full (absolute) VFS path — always absolute, recursive or not type: 'file' | 'directory' | 'symlink' entityId: string // Underlying entity ID } @@ -240,7 +249,15 @@ export interface ReaddirOptions { withFileTypes?: boolean // Return Dirent objects // VFS-specific options - recursive?: boolean // Include subdirectories + /** + * List every descendant (files and directories, all depths), not just + * direct children. Entries are reported as paths RELATIVE TO THE QUERIED + * DIRECTORY (Node's `fs.readdir(dir, { recursive: true })` convention) — + * a string-array result contains e.g. `'sub/file.txt'`, and with + * `withFileTypes: true` each `VFSDirent.name` carries that same relative + * path (see {@link VFSDirent}). Default: `false` (direct children only). + */ + recursive?: boolean limit?: number // Max results offset?: number // Skip N results cursor?: string // Pagination cursor diff --git a/tests/unit/vfs-readdir-recursive.test.ts b/tests/unit/vfs-readdir-recursive.test.ts new file mode 100644 index 00000000..2ee8a775 --- /dev/null +++ b/tests/unit/vfs-readdir-recursive.test.ts @@ -0,0 +1,99 @@ +/** + * vfs.readdir()'s `recursive` option: typed since 7.30 but never read, so it + * silently behaved exactly like `recursive: false`. This pins the real, + * documented contract: a recursive listing returns every descendant (files + * AND directories, all depths) as paths RELATIVE TO THE QUERIED DIRECTORY — + * the same convention Node's `fs.readdir(dir, { recursive: true })` uses — + * for both the plain string-array form and the `withFileTypes` VFSDirent + * form (whose `name` carries that same relative path when recursive). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Brainy } from '../../src/brainy.js' +import type { VFSDirent } from '../../src/vfs/types.js' + +describe('vfs.readdir() recursive option', () => { + let brain: Brainy + + beforeEach(async () => { + brain = new Brainy({ + requireSubtype: false, + storage: { type: 'memory' }, + silent: true + }) + await brain.init() + + // Build: + // /a/b.txt + // /a/sub/c.txt + // /a/sub/deeper/d.txt + // /a/sub2/ (empty directory) + await brain.vfs.writeFile('/a/b.txt', 'B') + await brain.vfs.writeFile('/a/sub/c.txt', 'C') + await brain.vfs.writeFile('/a/sub/deeper/d.txt', 'D') + await brain.vfs.mkdir('/a/sub2', { recursive: true }) + }) + + afterEach(async () => { + await brain.close() + }) + + it('non-recursive (default) still returns only direct children, by basename', async () => { + const entries = await brain.vfs.readdir('/a') as string[] + expect([...entries].sort()).toEqual(['b.txt', 'sub', 'sub2']) + }) + + it('recursive: true returns every descendant as a path relative to the queried directory', async () => { + const entries = await brain.vfs.readdir('/a', { recursive: true }) as string[] + expect([...entries].sort()).toEqual([ + 'b.txt', + 'sub', + 'sub/c.txt', + 'sub/deeper', + 'sub/deeper/d.txt', + 'sub2' + ]) + }) + + it('recursive: true at the root has no leading slash on relative entries', async () => { + const entries = await brain.vfs.readdir('/', { recursive: true }) as string[] + expect(entries).toContain('a') + expect(entries).toContain('a/b.txt') + expect(entries).toContain('a/sub/deeper/d.txt') + for (const entry of entries) { + expect(entry.startsWith('/')).toBe(false) + } + }) + + it('recursive + withFileTypes: VFSDirent.name is the relative path, .path stays absolute', async () => { + const entries = await brain.vfs.readdir('/a', { + recursive: true, + withFileTypes: true + }) as VFSDirent[] + + const byName = new Map(entries.map((e) => [e.name, e])) + + const nested = byName.get('sub/deeper/d.txt') + expect(nested).toBeDefined() + expect(nested!.path).toBe('/a/sub/deeper/d.txt') + expect(nested!.type).toBe('file') + + const nestedDir = byName.get('sub/deeper') + expect(nestedDir).toBeDefined() + expect(nestedDir!.path).toBe('/a/sub/deeper') + expect(nestedDir!.type).toBe('directory') + + // Non-recursive VFSDirent behavior is unchanged: name is the basename. + const direct = await brain.vfs.readdir('/a', { withFileTypes: true }) as VFSDirent[] + const directEntry = direct.find((e) => e.path === '/a/b.txt') + expect(directEntry?.name).toBe('b.txt') + }) + + it('recursive + filter composes: only files survive a type filter', async () => { + const entries = await brain.vfs.readdir('/a', { + recursive: true, + filter: { type: 'file' } + }) as string[] + expect([...entries].sort()).toEqual(['b.txt', 'sub/c.txt', 'sub/deeper/d.txt']) + }) +})