Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/api/copy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileResult>} files - Array of file results
* @property {Array<FileResult>} files - Full file results (includes content). Use `manifest` when you only need paths and sizes.
* @property {Array<ManifestEntry>} 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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions tests/types/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ScanOptions,
FormatOptions,
FileResult,
ManifestEntry,
} from 'copytree';

// ============================================================================
Expand Down Expand Up @@ -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 = {
Expand Down
71 changes: 71 additions & 0 deletions tests/unit/api/copy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
47 changes: 46 additions & 1 deletion types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
*/
Expand Down Expand Up @@ -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 */
Expand Down
Loading