340 lines
9.6 KiB
TypeScript
340 lines
9.6 KiB
TypeScript
/**
|
|
* 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<IDirectoryEntry[]> {
|
|
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<void> {
|
|
return this.provider.createDirectory(this.path, {
|
|
recursive: this.options.recursive,
|
|
mode: this.options.mode,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete the directory
|
|
*/
|
|
public async delete(): Promise<void> {
|
|
return this.provider.deleteDirectory(this.path, {
|
|
recursive: this.options.recursive,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if the directory exists
|
|
* @returns True if directory exists
|
|
*/
|
|
public async exists(): Promise<boolean> {
|
|
return this.provider.directoryExists(this.path);
|
|
}
|
|
|
|
/**
|
|
* Get directory statistics
|
|
* @returns Directory stats
|
|
*/
|
|
public async stat(): Promise<IFileStats> {
|
|
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<void> {
|
|
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<void> {
|
|
// 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<string> {
|
|
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');
|
|
}
|
|
}
|