From b76494218385cb379eee55ced6082266776cac4c Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 16 Dec 2025 10:02:36 +0000 Subject: [PATCH] feat(smartfs.directory): feat(smartfs.directory): add directory copy/move with conflict handling and options --- changelog.md | 9 ++ npmextra.json | 16 ++- test/test.node.provider.ts | 183 ++++++++++++++++++++++++++++++++ ts/00_commitinfo_data.ts | 2 +- ts/classes/smartfs.directory.ts | 149 +++++++++++++++++++++++++- 5 files changed, 352 insertions(+), 7 deletions(-) diff --git a/changelog.md b/changelog.md index 7fc1b24..788df05 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2025-12-16 - 1.3.0 - feat(smartfs.directory) +feat(smartfs.directory): add directory copy/move with conflict handling and options + +- Implement Directory.copy(targetPath) and Directory.move(targetPath) with provider-backed file operations (createDirectory, listDirectory, copyFile, deleteDirectory). +- Add new directory options and fluent setters: applyFilter, overwrite, preserveTimestamps, onConflict (defaults: applyFilter=true, overwrite=false, preserveTimestamps=false, onConflict='merge'). +- Copy supports recursive listing, optional filtering (applyFilter), overwrite behavior and timestamp preservation; onConflict supports 'merge'|'error'|'replace'. Move performs copy then deletes the source. +- Add comprehensive tests for copy/move: basic copy, recursive copy, filter-based copy, applyFilter(false) behavior, overwrite handling, onConflict error/replace cases, move semantics, and copying empty directories. +- Update npmextra.json to use scoped keys (@git.zone/cli, @ship.zone/szci) and add release registry/access configuration. + ## 2025-12-02 - 1.2.0 - feat(smartfs.directory) Add directory treeHash: deterministic content-based hashing of directory trees with streaming and algorithm option diff --git a/npmextra.json b/npmextra.json index 05a2a75..cd63ff2 100644 --- a/npmextra.json +++ b/npmextra.json @@ -1,5 +1,5 @@ { - "gitzone": { + "@git.zone/cli": { "projectType": "npm", "module": { "githost": "code.foss.global", @@ -9,10 +9,16 @@ "npmPackagename": "@push.rocks/smartfs", "license": "MIT", "projectDomain": "push.rocks" + }, + "release": { + "registries": [ + "https://verdaccio.lossless.digital", + "https://registry.npmjs.org" + ], + "accessLevel": "public" } }, - "npmci": { - "npmGlobalTools": [], - "npmAccessLevel": "public" + "@ship.zone/szci": { + "npmGlobalTools": [] } -} +} \ No newline at end of file diff --git a/test/test.node.provider.ts b/test/test.node.provider.ts index cf5f230..bfce7eb 100644 --- a/test/test.node.provider.ts +++ b/test/test.node.provider.ts @@ -367,6 +367,189 @@ tap.test('treeHash of empty directory should return consistent hash', async () = expect(hash1.length).toEqual(64); }); +// --- Directory copy/move tests --- + +tap.test('should copy a directory', async () => { + const sourcePath = path.join(tempDir, 'copy-dir-source'); + const destPath = path.join(tempDir, 'copy-dir-dest'); + + await smartFs.directory(sourcePath).create(); + await smartFs.file(path.join(sourcePath, 'file1.txt')).write('content1'); + await smartFs.file(path.join(sourcePath, 'file2.txt')).write('content2'); + + await smartFs.directory(sourcePath).copy(destPath); + + // Source should still exist + const sourceExists = await smartFs.directory(sourcePath).exists(); + expect(sourceExists).toEqual(true); + + // Destination should exist with same files + const destExists = await smartFs.directory(destPath).exists(); + expect(destExists).toEqual(true); + + const destContent1 = await smartFs.file(path.join(destPath, 'file1.txt')).encoding('utf8').read(); + const destContent2 = await smartFs.file(path.join(destPath, 'file2.txt')).encoding('utf8').read(); + + expect(destContent1).toEqual('content1'); + expect(destContent2).toEqual('content2'); +}); + +tap.test('should copy a directory recursively with nested subdirectories', async () => { + const sourcePath = path.join(tempDir, 'copy-recursive-source'); + const destPath = path.join(tempDir, 'copy-recursive-dest'); + + await smartFs.directory(sourcePath).create(); + await smartFs.file(path.join(sourcePath, 'root.txt')).write('root'); + await smartFs.directory(path.join(sourcePath, 'sub1')).create(); + await smartFs.file(path.join(sourcePath, 'sub1', 'nested1.txt')).write('nested1'); + await smartFs.directory(path.join(sourcePath, 'sub1', 'sub2')).create(); + await smartFs.file(path.join(sourcePath, 'sub1', 'sub2', 'deep.txt')).write('deep'); + + await smartFs.directory(sourcePath).copy(destPath); + + // Verify all files copied + expect(await smartFs.file(path.join(destPath, 'root.txt')).exists()).toEqual(true); + expect(await smartFs.file(path.join(destPath, 'sub1', 'nested1.txt')).exists()).toEqual(true); + expect(await smartFs.file(path.join(destPath, 'sub1', 'sub2', 'deep.txt')).exists()).toEqual(true); + + const deepContent = await smartFs.file(path.join(destPath, 'sub1', 'sub2', 'deep.txt')).encoding('utf8').read(); + expect(deepContent).toEqual('deep'); +}); + +tap.test('should copy directory with filter applied', async () => { + const sourcePath = path.join(tempDir, 'copy-filter-source'); + const destPath = path.join(tempDir, 'copy-filter-dest'); + + await smartFs.directory(sourcePath).create(); + await smartFs.file(path.join(sourcePath, 'file.ts')).write('typescript'); + await smartFs.file(path.join(sourcePath, 'file.js')).write('javascript'); + await smartFs.file(path.join(sourcePath, 'file.txt')).write('text'); + + // Copy only .ts files + await smartFs.directory(sourcePath).filter(/\.ts$/).copy(destPath); + + expect(await smartFs.file(path.join(destPath, 'file.ts')).exists()).toEqual(true); + expect(await smartFs.file(path.join(destPath, 'file.js')).exists()).toEqual(false); + expect(await smartFs.file(path.join(destPath, 'file.txt')).exists()).toEqual(false); +}); + +tap.test('should copy all files when applyFilter(false)', async () => { + const sourcePath = path.join(tempDir, 'copy-no-filter-source'); + const destPath = path.join(tempDir, 'copy-no-filter-dest'); + + await smartFs.directory(sourcePath).create(); + await smartFs.file(path.join(sourcePath, 'file.ts')).write('typescript'); + await smartFs.file(path.join(sourcePath, 'file.js')).write('javascript'); + + // Filter is set but applyFilter(false) ignores it + await smartFs.directory(sourcePath).filter(/\.ts$/).applyFilter(false).copy(destPath); + + expect(await smartFs.file(path.join(destPath, 'file.ts')).exists()).toEqual(true); + expect(await smartFs.file(path.join(destPath, 'file.js')).exists()).toEqual(true); +}); + +tap.test('should copy with overwrite(true) replacing existing files', async () => { + const sourcePath = path.join(tempDir, 'copy-overwrite-source'); + const destPath = path.join(tempDir, 'copy-overwrite-dest'); + + await smartFs.directory(sourcePath).create(); + await smartFs.file(path.join(sourcePath, 'file.txt')).write('new content'); + + await smartFs.directory(destPath).create(); + await smartFs.file(path.join(destPath, 'file.txt')).write('old content'); + + await smartFs.directory(sourcePath).overwrite(true).copy(destPath); + + const content = await smartFs.file(path.join(destPath, 'file.txt')).encoding('utf8').read(); + expect(content).toEqual('new content'); +}); + +tap.test('should throw error when onConflict is error and target exists', async () => { + const sourcePath = path.join(tempDir, 'copy-conflict-error-source'); + const destPath = path.join(tempDir, 'copy-conflict-error-dest'); + + await smartFs.directory(sourcePath).create(); + await smartFs.file(path.join(sourcePath, 'file.txt')).write('content'); + await smartFs.directory(destPath).create(); + + let threw = false; + try { + await smartFs.directory(sourcePath).onConflict('error').copy(destPath); + } catch (e: any) { + threw = true; + expect(e.message).toInclude('EEXIST'); + } + expect(threw).toEqual(true); +}); + +tap.test('should replace target when onConflict is replace', async () => { + const sourcePath = path.join(tempDir, 'copy-replace-source'); + const destPath = path.join(tempDir, 'copy-replace-dest'); + + await smartFs.directory(sourcePath).create(); + await smartFs.file(path.join(sourcePath, 'new.txt')).write('new'); + + await smartFs.directory(destPath).create(); + await smartFs.file(path.join(destPath, 'old.txt')).write('old'); + + await smartFs.directory(sourcePath).onConflict('replace').copy(destPath); + + // Old file should be gone, new file should exist + expect(await smartFs.file(path.join(destPath, 'old.txt')).exists()).toEqual(false); + expect(await smartFs.file(path.join(destPath, 'new.txt')).exists()).toEqual(true); +}); + +tap.test('should move a directory', async () => { + const sourcePath = path.join(tempDir, 'move-dir-source'); + const destPath = path.join(tempDir, 'move-dir-dest'); + + await smartFs.directory(sourcePath).create(); + await smartFs.file(path.join(sourcePath, 'file1.txt')).write('content1'); + await smartFs.file(path.join(sourcePath, 'file2.txt')).write('content2'); + + await smartFs.directory(sourcePath).move(destPath); + + // Source should no longer exist + const sourceExists = await smartFs.directory(sourcePath).exists(); + expect(sourceExists).toEqual(false); + + // Destination should exist with files + const destExists = await smartFs.directory(destPath).exists(); + expect(destExists).toEqual(true); + + const destContent1 = await smartFs.file(path.join(destPath, 'file1.txt')).encoding('utf8').read(); + expect(destContent1).toEqual('content1'); +}); + +tap.test('should move directory recursively', async () => { + const sourcePath = path.join(tempDir, 'move-recursive-source'); + const destPath = path.join(tempDir, 'move-recursive-dest'); + + await smartFs.directory(sourcePath).create(); + await smartFs.file(path.join(sourcePath, 'root.txt')).write('root'); + await smartFs.directory(path.join(sourcePath, 'sub')).create(); + await smartFs.file(path.join(sourcePath, 'sub', 'nested.txt')).write('nested'); + + await smartFs.directory(sourcePath).move(destPath); + + // Source should not exist + expect(await smartFs.directory(sourcePath).exists()).toEqual(false); + + // All files should be at destination + expect(await smartFs.file(path.join(destPath, 'root.txt')).exists()).toEqual(true); + expect(await smartFs.file(path.join(destPath, 'sub', 'nested.txt')).exists()).toEqual(true); +}); + +tap.test('should copy empty directory', async () => { + const sourcePath = path.join(tempDir, 'copy-empty-source'); + const destPath = path.join(tempDir, 'copy-empty-dest'); + + await smartFs.directory(sourcePath).create(); + await smartFs.directory(sourcePath).copy(destPath); + + expect(await smartFs.directory(destPath).exists()).toEqual(true); +}); + tap.test('cleanup temp directory', async () => { await fs.rm(tempDir, { recursive: true, force: true }); expect(true).toEqual(true); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 36a7976..4e69f83 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartfs', - version: '1.2.0', + version: '1.3.0', description: 'a cross platform extendable fs module' } diff --git a/ts/classes/smartfs.directory.ts b/ts/classes/smartfs.directory.ts index 118d25d..345f1b9 100644 --- a/ts/classes/smartfs.directory.ts +++ b/ts/classes/smartfs.directory.ts @@ -27,7 +27,18 @@ export class SmartFsDirectory { mode?: TFileMode; filter?: string | RegExp | ((entry: IDirectoryEntry) => boolean); includeStats?: boolean; - } = {}; + // Copy/move options + applyFilter?: boolean; + overwrite?: boolean; + preserveTimestamps?: boolean; + onConflict?: 'merge' | 'error' | 'replace'; + } = { + // Defaults for copy/move + applyFilter: true, + overwrite: false, + preserveTimestamps: false, + onConflict: 'merge', + }; constructor(provider: ISmartFsProvider, path: string) { this.provider = provider; @@ -82,6 +93,46 @@ export class SmartFsDirectory { return this; } + /** + * Control whether filter() is applied during copy/move operations + * @param apply - If true, only copy/move files matching filter; if false, copy/move all files + * @default true + */ + public applyFilter(apply: boolean = true): this { + this.options.applyFilter = apply; + return this; + } + + /** + * Control whether to overwrite existing files during copy/move + * @param overwrite - If true, overwrite existing files; if false, throw error on conflict + * @default false + */ + public overwrite(overwrite: boolean = true): this { + this.options.overwrite = overwrite; + return this; + } + + /** + * Control whether to preserve file timestamps during copy/move + * @param preserve - If true, preserve original timestamps; if false, use current time + * @default false + */ + public preserveTimestamps(preserve: boolean = true): this { + this.options.preserveTimestamps = preserve; + return this; + } + + /** + * Control behavior when target directory already exists + * @param behavior - 'merge' to merge contents, 'error' to throw, 'replace' to delete and recreate + * @default 'merge' + */ + public onConflict(behavior: 'merge' | 'error' | 'replace'): this { + this.options.onConflict = behavior; + return this; + } + // --- Action Methods (return Promises) --- /** @@ -132,6 +183,102 @@ export class SmartFsDirectory { return this.provider.directoryStat(this.path); } + /** + * Copy the directory to a new location + * @param targetPath - Destination path + * + * @example + * ```typescript + * // Basic copy + * await fs.directory('/source').copy('/target'); + * + * // Copy with options + * await fs.directory('/source') + * .recursive() + * .filter('*.ts') + * .overwrite(true) + * .preserveTimestamps(true) + * .copy('/target'); + * + * // Copy all files (ignore filter) + * await fs.directory('/source') + * .applyFilter(false) + * .copy('/target'); + * ``` + */ + public async copy(targetPath: string): Promise { + const normalizedTarget = this.provider.normalizePath(targetPath); + + // Handle conflict behavior + const targetExists = await this.provider.directoryExists(normalizedTarget); + if (targetExists) { + if (this.options.onConflict === 'error') { + throw new Error(`EEXIST: directory already exists: ${normalizedTarget}`); + } + if (this.options.onConflict === 'replace') { + await this.provider.deleteDirectory(normalizedTarget, { recursive: true }); + } + // 'merge' (default) - continue and overwrite based on file settings + } + + // Create target directory + await this.provider.createDirectory(normalizedTarget, { recursive: true }); + + // List entries (always recursive for copy, respects filter based on applyFilter option) + const listOptions: IListOptions = { + recursive: true, + filter: this.options.applyFilter ? this.options.filter : undefined, + includeStats: false, + }; + const entries = await this.provider.listDirectory(this.path, listOptions); + + // Process entries - sort to ensure directories are created before their contents + const sortedEntries = entries.sort((a, b) => a.path.localeCompare(b.path)); + + for (const entry of sortedEntries) { + const relativePath = entry.path.substring(this.path.length); + const targetEntryPath = this.provider.joinPath(normalizedTarget, relativePath); + + if (entry.isDirectory) { + await this.provider.createDirectory(targetEntryPath, { recursive: true }); + } else { + // Ensure parent directory exists + const parentPath = targetEntryPath.substring(0, targetEntryPath.lastIndexOf('/')); + if (parentPath && parentPath !== normalizedTarget) { + await this.provider.createDirectory(parentPath, { recursive: true }); + } + // Copy file using provider + await this.provider.copyFile(entry.path, targetEntryPath, { + preserveTimestamps: this.options.preserveTimestamps, + overwrite: this.options.overwrite, + }); + } + } + } + + /** + * Move the directory to a new location + * @param targetPath - Destination path + * + * @example + * ```typescript + * // Basic move + * await fs.directory('/source').move('/target'); + * + * // Move with conflict handling + * await fs.directory('/source') + * .onConflict('replace') + * .move('/target'); + * ``` + */ + public async move(targetPath: string): Promise { + // Copy first using current configuration + await this.copy(targetPath); + + // Delete source (always recursive, regardless of filter - this matches mv behavior) + await this.provider.deleteDirectory(this.path, { recursive: true }); + } + /** * Get the directory path */