From 7b77c07f7d8eadc2e6806c32de7c61ea6a946a72 Mon Sep 17 00:00:00 2001 From: Greg Priday Date: Thu, 26 Feb 2026 13:57:08 +1100 Subject: [PATCH] feat(api): add manifest field to CopyResult for lightweight file list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ManifestEntry interface (path + size, no content) to types/index.d.ts - Add manifest: ManifestEntry[] to CopyResult — always populated in both normal and dry-run paths - Derive manifest via files.map() in src/api/copy.js (O(n) projection, no content retained) - Add JSDoc @typedef ManifestEntry and update CopyResult docs with usage examples - Add 8 manifest tests to tests/unit/api/copy.test.js covering shape, parity with files, dry-run, filtering, and zero-result edge case - Add ManifestEntry type assertions to tests/types/api.test.ts --- src/api/copy.js | 26 +++++++++++++- tests/types/api.test.ts | 8 +++++ tests/unit/api/copy.test.js | 71 +++++++++++++++++++++++++++++++++++++ types/index.d.ts | 47 +++++++++++++++++++++++- 4 files changed, 150 insertions(+), 2 deletions(-) diff --git a/src/api/copy.js b/src/api/copy.js index 9f78b5b..999b6d6 100644 --- a/src/api/copy.js +++ b/src/api/copy.js @@ -28,10 +28,19 @@ import Clipboard from '../utils/clipboard.js'; * copy operations with different configurations. */ +/** + * @typedef {Object} ManifestEntry + * @property {string} path - Relative POSIX path to the file + * @property {number} size - File size in bytes + */ + /** * @typedef {Object} CopyResult * @property {string} output - Formatted output string - * @property {Array} files - Array of file results + * @property {Array} files - Full file results (includes content). Use `manifest` when you only need paths and sizes. + * @property {Array} manifest - Lightweight list of included files with only `path` and `size`. + * Safe to retain in long-lived processes (e.g. Electron) without holding megabytes of file content in memory. + * Consistent shape across normal runs and dry runs — entries never include `content`. * @property {Object} stats - Processing statistics * @property {number} stats.totalFiles - Total number of files processed * @property {number} stats.duration - Processing duration in milliseconds @@ -71,11 +80,20 @@ import Clipboard from '../utils/clipboard.js'; * }); * * @example + * // Access the lightweight file manifest (no content retained in memory) + * const result = await copy('./src'); + * result.manifest.forEach(({ path, size }) => { + * console.log(`${path}: ${size} bytes`); + * }); + * + * @example * // Dry run to preview * const result = await copy('./src', { * dryRun: true * }); * console.log(`Would process ${result.stats.totalFiles} files`); + * // manifest is also available in dry-run mode + * result.manifest.forEach(({ path }) => console.log(path)); */ export async function copy(basePath, options = {}) { const startTime = Date.now(); @@ -106,10 +124,12 @@ export async function copy(basePath, options = {}) { } const totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0); + const manifest = files.map((file) => ({ path: file.path, size: file.size || 0 })); return { output: '', files: files, + manifest, stats: { totalFiles: files.length, duration: Date.now() - startTime, @@ -149,10 +169,14 @@ export async function copy(basePath, options = {}) { prettyPrint: options.prettyPrint, }); + // Build lightweight manifest (path + size only — no content) + const manifest = files.map((file) => ({ path: file.path, size: file.size || 0 })); + // Build result const result = { output, files, + manifest, stats: { totalFiles: files.length, duration: Date.now() - startTime, diff --git a/tests/types/api.test.ts b/tests/types/api.test.ts index 5b401f5..692026c 100644 --- a/tests/types/api.test.ts +++ b/tests/types/api.test.ts @@ -19,6 +19,7 @@ import { ScanOptions, FormatOptions, FileResult, + ManifestEntry, } from 'copytree'; // ============================================================================ @@ -213,6 +214,13 @@ async function testCopyApi() { const outputSize: number | undefined = result.stats.outputSize; const outputPath: string | undefined = result.outputPath; + // Manifest type checks + const manifest: ManifestEntry[] = result.manifest; + manifest.forEach((entry: ManifestEntry) => { + const entryPath: string = entry.path; + const entrySize: number = entry.size; + }); + // Copy with all options const config = await ConfigManager.create(); const options: CopyOptions = { diff --git a/tests/unit/api/copy.test.js b/tests/unit/api/copy.test.js index 4d20ec6..070e67f 100644 --- a/tests/unit/api/copy.test.js +++ b/tests/unit/api/copy.test.js @@ -235,6 +235,77 @@ describe('copy()', () => { }); }); + describe('Manifest', () => { + it('should include a manifest array in the result', async () => { + const result = await copy(testDir); + + expect(Array.isArray(result.manifest)).toBe(true); + expect(result.manifest.length).toBeGreaterThan(0); + }); + + it('should have correct shape on each manifest entry (path and size, no content)', async () => { + const result = await copy(testDir); + + result.manifest.forEach((entry) => { + expect(typeof entry.path).toBe('string'); + expect(entry.path.length).toBeGreaterThan(0); + expect(typeof entry.size).toBe('number'); + expect(entry.size).toBeGreaterThanOrEqual(0); + expect(entry).not.toHaveProperty('content'); + expect(entry).not.toHaveProperty('absolutePath'); + }); + }); + + it('should have manifest.length equal to stats.totalFiles', async () => { + const result = await copy(testDir); + + expect(result.manifest.length).toBe(result.stats.totalFiles); + }); + + it('should have manifest paths that match result.files paths', async () => { + const result = await copy(testDir); + + const manifestPaths = result.manifest.map((e) => e.path).sort(); + const filePaths = result.files.map((f) => f.path).sort(); + expect(manifestPaths).toEqual(filePaths); + }); + + it('should include manifest in dry run with same lightweight shape', async () => { + const result = await copy(testDir, { dryRun: true }); + + expect(Array.isArray(result.manifest)).toBe(true); + expect(result.manifest.length).toBeGreaterThan(0); + result.manifest.forEach((entry) => { + expect(typeof entry.path).toBe('string'); + expect(typeof entry.size).toBe('number'); + expect(entry).not.toHaveProperty('content'); + }); + }); + + it('should have manifest.length equal to stats.totalFiles in dry run', async () => { + const result = await copy(testDir, { dryRun: true }); + + expect(result.manifest.length).toBe(result.stats.totalFiles); + }); + + it('should reflect file filters in manifest', async () => { + const result = await copy(testDir, { filter: ['**/*.js'] }); + + result.manifest.forEach((entry) => { + expect(entry.path).toMatch(/\.js$/); + }); + }); + + it('should return an empty manifest in dry run when filter matches no files', async () => { + // format() throws when files is empty, so use dryRun to test the zero-result path + const result = await copy(testDir, { filter: ['**/*.nope_no_match'], dryRun: true }); + + expect(Array.isArray(result.manifest)).toBe(true); + expect(result.manifest.length).toBe(0); + expect(result.manifest.length).toBe(result.stats.totalFiles); + }); + }); + describe('Performance', () => { it('should complete within reasonable time', async () => { const start = Date.now(); diff --git a/types/index.d.ts b/types/index.d.ts index 9bffa9f..b465fae 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -68,6 +68,26 @@ export interface Logger { // File Results // ============================================================================ +/** + * Lightweight manifest entry representing a single included file. + * Contains only path and size — no file content — making it safe to retain + * in long-lived UI processes (e.g. Electron apps) without holding megabytes + * of file data in memory. + * + * @example + * const result = await copy('./src'); + * // Show a file breakdown without retaining file content + * result.manifest.forEach(({ path, size }) => { + * console.log(`${path} (${size} bytes)`); + * }); + */ +export interface ManifestEntry { + /** Relative path to the file (e.g. "src/utils/helpers.js"). Same value as `FileResult.path`. */ + path: string; + /** File size in bytes */ + size: number; +} + /** * Represents a file discovered and processed by scan() */ @@ -294,8 +314,33 @@ export interface CopyOptions extends ScanOptions, FormatOptions { export interface CopyResult { /** Formatted output string */ output: string; - /** Array of file results */ + /** + * Full file results including content, metadata, and git status. + * Use `manifest` instead when you only need paths and sizes — it avoids + * retaining megabytes of file content in memory. + */ files: FileResult[]; + /** + * Lightweight manifest of included files — an array of `{ path, size }` objects. + * Ideal for building UI file-breakdowns (e.g. tooltips or lists) without + * holding the full file content in memory. + * + * Consistent shape across both normal runs and dry runs: entries always have + * `path` and `size` (bytes), never `content` — but the set of files in the + * manifest follows the same inclusion rules as `result.files` for each mode. + * + * @example + * const result = await copy('./src'); + * result.manifest.forEach(({ path, size }) => { + * console.log(`${path}: ${size} bytes`); + * }); + * + * @example + * // Dry run: get file list without processing content + * const { manifest } = await copy('./src', { dryRun: true }); + * console.log(`Would include ${manifest.length} files`); + */ + manifest: ManifestEntry[]; /** Processing statistics */ stats: { /** Total number of files processed */