diff --git a/src/utils/io/file-info.ts b/src/utils/io/file-info.ts new file mode 100644 index 0000000..4608555 --- /dev/null +++ b/src/utils/io/file-info.ts @@ -0,0 +1,242 @@ +import { $i } from "@/utils/collections"; +import { FileNotFoundError } from "@/utils/errors"; +import glob from "fast-glob"; +import { ReadStream, createReadStream, existsSync, readFileSync as readFileNodeSync, statSync } from "node:fs"; +import { readFile as readFileNode } from "node:fs/promises"; +import { basename, dirname } from "node:path"; + +/** + * Represents a file and provides utility methods to access its properties. + */ +export class FileInfo { + /** + * The file path. + */ + private readonly _path: string; + + /** + * Constructs a new {@link FileInfo} instance. + * + * @param path - The file path. + */ + constructor(path: string) { + this._path = path; + } + + /** + * Casts the given value to a {@link FileInfo} instance. + * + * @param file - The file path, or a {@link FileInfo} instance. + * + * @returns A {@link FileInfo} instance, or `undefined` if the input could not be casted to such. + */ + static of(file: string | FileInfo): FileInfo { + if (file instanceof FileInfo) { + return file; + } + + return new FileInfo(String(file)); + } + + /** + * Gets the file name. + */ + get name(): string { + return basename(this._path); + } + + /** + * Gets the directory name of the file. + */ + get directoryName(): string { + return dirname(this._path); + } + + /** + * Gets the file path. + */ + get path(): string { + return this._path; + } + + /** + * Checks if the file exists in the file system. + */ + get exists(): boolean { + return existsSync(this._path); + } + + /** + * Returns the size of the file in bytes. + */ + get size(): number { + return statSync(this._path).size; + } + + /** + * Gets the file path. + * + * Used to automatically convert this instance to a `Blob`. + */ + get [Symbol.for("path")](): string { + return this._path; + } + + /** + * Creates a readable stream from the file. + * + * @param encoding - The character encoding for the file. + * + * @returns A `ReadStream` instance. + */ + stream(encoding?: BufferEncoding): ReadStream { + return createReadStream(this._path, encoding); + } + + /** + * Reads the file and returns its content as a buffer. + * + * @returns A `Promise` that resolves to a `Buffer` containing the file content. + */ + buffer(): Promise<Buffer> { + return readFileNode(this._path); + } + + /** + * Reads the file and returns its content as a string. + * + * @param encoding - The character encoding for the file. + * + * @returns A `Promise` that resolves to a string containing the file content. + */ + async text(encoding?: BufferEncoding): Promise<string> { + return (await this.buffer()).toString(encoding); + } + + /** + * Reads the file and returns its content as a JSON object. + * + * @template T - The type of the object. + * + * @param encoding - The character encoding for the file. + * + * @returns A `Promise` that resolves to a JSON object containing the file content. + */ + async json<T = unknown>(encoding?: BufferEncoding): Promise<T> { + return JSON.parse(await this.text(encoding)); + } + + /** + * Returns the file path. + * + * @returns The file path. + */ + toString() { + return this._path; + } +} + +/** + * Compares two {@link FileInfo} objects or file paths for equality. + * + * @param left - {@link FileInfo} object or file path. + * @param right - {@link FileInfo} object or file path. + * + * @returns `true` if both {@link FileInfo} objects or file paths are equal; otherwise, `false`. + */ +export function fileEquals(left: FileInfo | string, right: FileInfo | string): boolean { + const leftPath = typeof left === "string" ? left : left?.path; + const rightPath = typeof right === "string" ? right : right?.path; + + return leftPath === rightPath; +} + +/** + * Asynchronously finds files that match the given pattern(s). + * + * @param pattern - A glob pattern or an array of glob patterns to match. + * + * @returns A `Promise` that resolves to an array of {@link FileInfo} objects. + */ +export async function findFiles(pattern: string | string[]): Promise<FileInfo[]> { + const patterns = Array.isArray(pattern) ? pattern : [pattern]; + const files = await Promise.all(patterns.map(x => glob(x))); + return $i(files).flatMap(x => x).distinct().map(x => new FileInfo(x)).toArray(); +} + +/** + * Synchronously finds files that match the given pattern(s). + * + * @param pattern - A glob pattern or an array of glob patterns to match. + * + * @returns An array of {@link FileInfo} objects. + */ +export function findFilesSync(pattern: string | string[]): FileInfo[] { + const patterns = Array.isArray(pattern) ? pattern : [pattern]; + const files = patterns.map(x => glob.sync(x)); + return $i(files).flatMap(x => x).distinct().map(x => new FileInfo(x)).toArray(); +} + +/** + * Reads the contents of the first file matching the specified glob pattern asynchronously. + * + * @param pattern - The glob pattern to match. + * + * @returns A promise that resolves to a Buffer containing the file contents. + * + * @throws {FileNotFoundError} - If no files matching the pattern are found. + */ +export async function readFile(pattern: string): Promise<Buffer> { + const files = await glob(pattern); + if (!files?.length) { + throw new FileNotFoundError(pattern); + } + + return await readFileNode(files[0]); +} + +/** + * Reads the contents of the first file matching the specified glob pattern asynchronously and returns it as a string. + * + * @param pattern - The glob pattern to match. + * @param encoding - The optional encoding to use for reading the file. Defaults to `utf8`. + * + * @returns A promise that resolves to a string containing the file contents. + * + * @throws {FileNotFoundError} - If no files matching the pattern are found. + */ +export async function readAllText(pattern: string, encoding?: BufferEncoding): Promise<string> { + return (await readFile(pattern)).toString(encoding); +} + +/** + * Reads the contents of the first file matching the specified glob pattern synchronously. + * + * @param pattern - The glob pattern to match. + * + * @returns A Buffer containing the file contents. + * + * @throws {FileNotFoundError} - If no files matching the pattern are found. + */ +export function readFileSync(pattern: string): Buffer { + const files = glob.sync(pattern); + if (!files?.length) { + throw new FileNotFoundError(pattern); + } + + return readFileNodeSync(files[0]); +} + +/** + * Reads the contents of the first file matching the specified glob pattern synchronously and returns it as a string. + * + * @param pattern - The glob pattern to match. + * @param encoding - The optional encoding to use for reading the file. Defaults to `utf-8`. + * + * @returns A string containing the file contents. + * + * @throws {FileNotFoundError} - If no files matching the pattern are found. + */ +export function readAllTextSync(pattern: string, encoding?: BufferEncoding): string { + return readFileSync(pattern).toString(encoding); +} diff --git a/src/utils/io/file.ts b/src/utils/io/file.ts deleted file mode 100644 index 9358384..0000000 --- a/src/utils/io/file.ts +++ /dev/null @@ -1,68 +0,0 @@ -import fs from "fs"; -import path from "path"; -import glob from "fast-glob"; - -export type FileSelector = string | { primary?: string, secondary?: string }; - -export const gradleOutputSelector = { - primary: "build/libs/!(*-@(dev|sources|javadoc)).jar", - secondary: "build/libs/*-@(dev|sources|javadoc).jar" -}; - -export default class File { - public name: string; - public path: string; - - public constructor(filePath: string) { - this.name = path.basename(filePath); - this.path = filePath; - Object.freeze(this); - } - - public getStream(): fs.ReadStream { - return fs.createReadStream(this.path); - } - - public async getBuffer(): Promise<Buffer> { - return new Promise((resolve, reject) => { - fs.readFile(this.path, (error, data) => { - if (error) { - reject(error); - } else { - resolve(data); - } - }) - }); - } - - public equals(file: unknown): boolean { - return file instanceof File && file.path === this.path; - } - - public static async getFiles(files: FileSelector): Promise<File[]> { - if (!files || typeof files !== "string" && !files.primary && !files.secondary) { - return []; - } - - if (typeof files === "string") { - return (await glob(files)).map(x => new File(x)); - } - - let results = []; - if (files.primary) { - results = (await glob(files.primary)).map(x => new File(x)); - } - if (files.secondary) { - results = results.concat((await glob(files.secondary)).map(x => new File(x))); - } - return results.filter((x, i, self) => self.findIndex(y => x.equals(y)) === i); - } - - public static async getRequiredFiles(files: FileSelector): Promise<File[] | never> { - const foundFiles = await File.getFiles(files); - if (foundFiles && foundFiles.length) { - return foundFiles; - } - throw new Error(`Specified files ('${typeof files === "string" ? files : [files.primary, files.secondary].filter(x => x).join(", ")}') were not found`); - } -} diff --git a/tests/unit/utils/io/file-info.spec.ts b/tests/unit/utils/io/file-info.spec.ts new file mode 100644 index 0000000..ab71814 --- /dev/null +++ b/tests/unit/utils/io/file-info.spec.ts @@ -0,0 +1,277 @@ +import { statSync } from "node:fs"; +import mockFs from "mock-fs"; +import { + FileInfo, + fileEquals, + findFiles, + findFilesSync, + readAllText, + readAllTextSync, + readFile, + readFileSync, +} from "@/utils/io/file-info"; + +beforeEach(() => { + mockFs({ + "path/to": { + "test.txt": "test", + "test.json": JSON.stringify({ foo: 42 }), + }, + }); +}); + +afterEach(() => { + mockFs.restore(); +}); + +describe("FileInfo", () => { + describe("constructor", () => { + test("constructs a new instance with the given path", () => { + const info = new FileInfo("path/to/test.txt"); + + expect(info.path).toBe("path/to/test.txt"); + }); + }); + + describe("of", () => { + test("constructs a new instance from the given path", () => { + const info = FileInfo.of("test.txt"); + + expect(info).toBeInstanceOf(FileInfo); + expect(info.path).toBe("test.txt"); + }); + + test("returns the same instance for a FileInfo object", () => { + const info1 = new FileInfo("test.txt"); + const info2 = FileInfo.of(info1); + + expect(info2).toBe(info1); + }); + }); + + describe("name", () => { + test("returns the file name", () => { + const info = new FileInfo("path/to/test.txt"); + + expect(info.name).toBe("test.txt"); + }); + }); + + describe("directoryName", () => { + test("returns the directory name of the file", () => { + const info = new FileInfo("path/to/test.txt"); + + expect(info.directoryName).toBe("path/to"); + }); + }); + + describe("path", () => { + test("returns the file path", () => { + const info = new FileInfo("path/to/test.txt"); + + expect(info.path).toBe("path/to/test.txt"); + }); + }); + + describe("exists", () => { + test("returns true for existing files", () => { + const info = new FileInfo("path/to/test.txt"); + + expect(info.exists).toBe(true); + }); + + test("returns false for non-existing files", () => { + const info = new FileInfo("path/to/not-test.txt"); + + expect(info.exists).toBe(false); + }); + }); + + describe("size", () => { + test("returns the file size", () => { + const info = new FileInfo("path/to/test.txt"); + + expect(info.size).toBe(statSync("path/to/test.txt").size); + }); + + test("throws if the file does not exist", () => { + const info = new FileInfo("path/to/not-test.txt"); + + expect(() => info.size).toThrow(); + }); + }); + + describe("stream", () => { + test("creates a readable stream", () => { + const info = new FileInfo("path/to/test.txt"); + const stream = info.stream(); + + expect(stream).toBeDefined(); + expect(stream.readable).toBe(true); + + stream.close(); + }); + }); + + describe("buffer", () => { + test("returns a promise that resolves to a buffer", async () => { + const info = new FileInfo("path/to/test.txt"); + const buffer = await info.buffer(); + + expect(Buffer.isBuffer(buffer)).toBe(true); + expect(buffer.toString()).toBe("test"); + }); + + test("throws if the file does not exist", async () => { + const info = new FileInfo("path/to/not-test.txt"); + + await expect(info.buffer()).rejects.toThrow(); + }); + }); + + describe("text", () => { + test("returns a promise that resolves to a string", async () => { + const info = new FileInfo("path/to/test.txt"); + const text = await info.text(); + + expect(text).toBe("test"); + }); + + test("throws if the file does not exist", async () => { + const info = new FileInfo("path/to/not-test.txt"); + + await expect(info.text()).rejects.toThrow(); + }); + }); + + describe("json", () => { + test("returns a promise that resolves to a json object", async () => { + const info = new FileInfo("path/to/test.json"); + const json = await info.json(); + + expect(json).toEqual({ foo: 42 }); + }); + + test("throws if the file does not exist", async () => { + const info = new FileInfo("path/to/not-test.json"); + + await expect(info.json()).rejects.toThrow(); + }); + }); + + describe("toString", () => { + test("returns the file path", () => { + const info = new FileInfo("path/to/test.txt"); + + expect(info.toString()).toBe("path/to/test.txt"); + }); + }); +}); + +describe("fileEquals", () => { + test("returns true for equal file paths", () => { + expect(fileEquals("path/to/test.txt", "path/to/test.txt")).toBe(true); + expect(fileEquals(FileInfo.of("path/to/test.txt"), "path/to/test.txt")).toBe(true); + expect(fileEquals("path/to/test.txt", FileInfo.of("path/to/test.txt"))).toBe(true); + expect(fileEquals(FileInfo.of("path/to/test.txt"), FileInfo.of("path/to/test.txt"))).toBe(true); + }); + + test("returns false for different file paths", () => { + expect(fileEquals("path/to/test.txt", "path/to/not-test.txt")).toBe(false); + expect(fileEquals(FileInfo.of("path/to/test.txt"), "path/to/not-test.txt")).toBe(false); + expect(fileEquals("path/to/test.txt", FileInfo.of("path/to/not-test.txt"))).toBe(false); + expect(fileEquals(FileInfo.of("path/to/test.txt"), FileInfo.of("path/to/not-test.txt"))).toBe(false); + }); +}); + +describe("findFiles", () => { + test("returns matching files for given pattern", async () => { + const files = await findFiles("path/to/test.*"); + const paths = files.map(file => file.path); + + expect(paths).toEqual(expect.arrayContaining([ + "path/to/test.txt", + "path/to/test.json", + ])); + }); + + test("respects the order of the given patterns", async () => { + const paths = ["path/to/test.json", "path/to/test.txt"]; + const variants = [paths, [...paths].reverse()]; + for (const variant of variants) { + const files = await findFiles(variant); + expect(files.map(x => x.path)).toEqual(variant); + } + }); +}); + +describe("findFilesSync", () => { + test("returns matching files for given pattern", () => { + const files = findFilesSync("path/to/test.*"); + const paths = files.map(file => file.path); + + expect(paths).toEqual(expect.arrayContaining([ + "path/to/test.txt", + "path/to/test.json", + ])); + }); + + test("respects the order of the given patterns", () => { + const paths = ["path/to/test.json", "path/to/test.txt"]; + const variants = [paths, [...paths].reverse()]; + for (const variant of variants) { + const files = findFilesSync(variant); + expect(files.map(x => x.path)).toEqual(variant); + } + }); +}); + +describe("readFile", () => { + test("reads the contents of the first matching file", async () => { + const content = await readFile("path/to/*.txt"); + + expect(Buffer.isBuffer(content)).toBe(true); + expect(content.toString()).toEqual("test"); + }); + + test("throws if no files were found", async () => { + await expect(readFile("path/from/*.txt")).rejects.toThrow(/path\/from\/\*\.txt/); + }); +}); + +describe("readFileSync", () => { + test("reads the contents of the first matching file", () => { + const content = readFileSync("path/to/*.txt"); + + expect(Buffer.isBuffer(content)).toBe(true); + expect(content.toString()).toEqual("test"); + }); + + test("throws if no files were found", () => { + expect(() => readFileSync("path/from/*.txt")).toThrow(/path\/from\/\*\.txt/); + }); +}); + +describe("readAllText", () => { + test("reads the contents of the first matching file as text", async () => { + const content = await readAllText("path/to/*.txt"); + + expect(content).toEqual("test"); + }); + + test("throws if no files were found", async () => { + await expect(readAllText("path/from/*.txt")).rejects.toThrow(/path\/from\/\*\.txt/); + }); +}); + +describe("readAllTextSync", () => { + test("reads the contents of the first matching file as text", () => { + const content = readAllTextSync("path/to/*.txt"); + + expect(content).toEqual("test"); + }); + + test("throws if no files were found", () => { + expect(() => readAllTextSync("path/from/*.txt")).toThrow(/path\/from\/\*\.txt/); + }); +});