mirror of
https://github.com/Kir-Antipov/mc-publish.git
synced 2024-11-22 00:11:02 -05:00
File
(-> FileInfo
) refactoring
This commit is contained in:
parent
79519efc22
commit
b9dc319a1e
3 changed files with 519 additions and 68 deletions
242
src/utils/io/file-info.ts
Normal file
242
src/utils/io/file-info.ts
Normal file
|
@ -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);
|
||||
}
|
|
@ -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`);
|
||||
}
|
||||
}
|
277
tests/unit/utils/io/file-info.spec.ts
Normal file
277
tests/unit/utils/io/file-info.spec.ts
Normal file
|
@ -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/);
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue