/** * Directory builder for fluent directory operations */ import * as crypto from 'crypto'; import type { ISmartFsProvider } from '../interfaces/mod.provider.js'; import type { TFileMode, IFileStats, IDirectoryEntry, IListOptions, ITreeHashOptions, } from '../interfaces/mod.types.js'; /** * Directory builder class for fluent directory operations * Configuration methods return `this` for chaining * Action methods return Promises for execution */ export class SmartFsDirectory { private provider: ISmartFsProvider; private path: string; // Configuration options private options: { recursive?: boolean; 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; this.path = this.provider.normalizePath(path); } // --- Configuration Methods (return this for chaining) --- /** * Enable recursive operations (for list, create, delete) */ public recursive(): this { this.options.recursive = true; return this; } /** * Set directory permissions/mode * @param mode - Directory mode (e.g., 0o755) */ public mode(mode: TFileMode): this { this.options.mode = mode; return this; } /** * Filter directory entries * @param filter - String pattern, RegExp, or filter function * * @example * ```typescript * // String pattern (glob-like) * .filter('*.ts') * * // RegExp * .filter(/\.ts$/) * * // Function * .filter(entry => entry.isFile && entry.name.endsWith('.ts')) * ``` */ public filter(filter: string | RegExp | ((entry: IDirectoryEntry) => boolean)): this { this.options.filter = filter; return this; } /** * Include file statistics in directory listings */ public includeStats(): this { this.options.includeStats = true; 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) --- /** * List directory contents * @returns Array of directory entries */ public async list(): Promise { const listOptions: IListOptions = { recursive: this.options.recursive, filter: this.options.filter, includeStats: this.options.includeStats, }; return this.provider.listDirectory(this.path, listOptions); } /** * Create the directory */ public async create(): Promise { return this.provider.createDirectory(this.path, { recursive: this.options.recursive, mode: this.options.mode, }); } /** * Delete the directory */ public async delete(): Promise { return this.provider.deleteDirectory(this.path, { recursive: this.options.recursive, }); } /** * Check if the directory exists * @returns True if directory exists */ public async exists(): Promise { return this.provider.directoryExists(this.path); } /** * Get directory statistics * @returns Directory stats */ public async stat(): Promise { 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 */ public getPath(): string { return this.path; } /** * Compute a hash of all files in the directory tree * Uses configured filter and recursive options * @param options - Hash options (algorithm defaults to 'sha256') * @returns Hex-encoded hash string * * @example * ```typescript * // Hash all files recursively * const hash = await fs.directory('/assets').recursive().treeHash(); * * // Hash only TypeScript files * const hash = await fs.directory('/src').filter('*.ts').recursive().treeHash(); * * // Use different algorithm * const hash = await fs.directory('/data').recursive().treeHash({ algorithm: 'sha512' }); * ``` */ public async treeHash(options?: ITreeHashOptions): Promise { const { algorithm = 'sha256' } = options ?? {}; const hash = crypto.createHash(algorithm); // Get all entries using existing filter/recursive configuration const entries = await this.list(); // Filter to files only and sort by path for deterministic ordering const files = entries .filter((entry) => entry.isFile) .sort((a, b) => a.path.localeCompare(b.path)); // Hash each file's relative path and contents for (const file of files) { // Compute relative path from directory root const relativePath = file.path.slice(this.path.length + 1); // Hash the relative path (with null separator) hash.update(relativePath + '\0'); // Stream file contents and update hash incrementally const stream = await this.provider.createReadStream(file.path); const reader = stream.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; hash.update(value); } } return hash.digest('hex'); } }