diff --git a/src/utils/io/file-info.ts b/src/utils/io/file-info.ts index c8b23d8..e887f4a 100644 --- a/src/utils/io/file-info.ts +++ b/src/utils/io/file-info.ts @@ -1,7 +1,8 @@ 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 StreamZip from "node-stream-zip"; +import { PathLike, ReadStream, createReadStream, existsSync, readFileSync as readFileNodeSync, statSync } from "node:fs"; import { readFile as readFileNode } from "node:fs/promises"; import { basename, dirname } from "node:path"; @@ -195,13 +196,9 @@ export function findFilesSync(pattern: string | string[]): FileInfo[] { * * @throws {FileNotFoundError} - If no files matching the pattern are found. */ -export async function readFile(pattern: string): Promise { - const files = await glob(pattern); - if (!files?.length) { - throw new FileNotFoundError(pattern); - } - - return await readFileNode(files[0]); +export async function readFile(pattern: PathLike): Promise { + const file = await getFileName(pattern); + return await readFileNode(file); } /** @@ -214,10 +211,45 @@ export async function readFile(pattern: string): Promise { * * @throws {FileNotFoundError} - If no files matching the pattern are found. */ -export async function readAllText(pattern: string, encoding?: BufferEncoding): Promise { +export async function readAllText(pattern: PathLike, encoding?: BufferEncoding): Promise { return (await readFile(pattern)).toString(encoding); } +/** + * Reads a zipped file and returns its content as a Buffer. + * + * @param pattern - The glob pattern used to locate the zip archive. + * @param entry - The entry name of the file within the zip archive. + * + * @returns A promise that resolves to a Buffer containing the file contents. + */ +export async function readZippedFile(pattern: PathLike, entry: string) : Promise { + const file = await getFileName(pattern); + + let zip = undefined as StreamZip.StreamZipAsync; + try { + // Dude, it's not my constructor, calm down. + // eslint-disable-next-line new-cap + zip = new StreamZip.async({ file }); + return await zip.entryData(entry); + } finally { + await zip?.close().catch(() => undefined); + } +} + +/** + * Reads a zipped file and returns its content as a string. + * + * @param pattern - The glob pattern used to locate the zip archive. + * @param entry - The entry name of the file within the zip archive. + * @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. + */ +export async function readAllZippedText(pattern: PathLike, entry: string, encoding?: BufferEncoding) : Promise { + return (await readZippedFile(pattern, entry)).toString(encoding); +} + /** * Reads the contents of the first file matching the specified glob pattern synchronously. * @@ -227,13 +259,9 @@ export async function readAllText(pattern: string, encoding?: BufferEncoding): P * * @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]); +export function readFileSync(pattern: PathLike): Buffer { + const file = getFileNameSync(pattern); + return readFileNodeSync(file); } /** @@ -246,6 +274,50 @@ export function readFileSync(pattern: string): Buffer { * * @throws {FileNotFoundError} - If no files matching the pattern are found. */ -export function readAllTextSync(pattern: string, encoding?: BufferEncoding): string { +export function readAllTextSync(pattern: PathLike, encoding?: BufferEncoding): string { return readFileSync(pattern).toString(encoding); } + +/** + * Retrieves the name of the first file that matches the specified glob pattern. + * + * @param pattern - The file path or glob pattern. + * + * @returns The name of the first matching file. + * + * @throws {FileNotFoundError} - If no matching file is found. + */ +async function getFileName(pattern: PathLike): Promise { + if (existsSync(pattern)) { + return pattern.toString(); + } + + const files = await glob(pattern.toString()); + if (files?.[0]) { + return files[0]; + } + + throw new FileNotFoundError(pattern.toString()); +} + +/** + * Synchronously retrieves the name of the first file that matches the specified glob pattern. + * + * @param pattern - The file path or glob pattern. + * + * @returns The name of the first matching file. + * + * @throws {FileNotFoundError} - If no matching file is found. + */ +function getFileNameSync(pattern: PathLike): string { + if (existsSync(pattern)) { + return pattern.toString(); + } + + const files = glob.sync(pattern.toString()); + if (files?.[0]) { + return files[0]; + } + + throw new FileNotFoundError(pattern.toString()); +} diff --git a/tests/unit/utils/io/file-info.spec.ts b/tests/unit/utils/io/file-info.spec.ts index 8a93ca9..4554a26 100644 --- a/tests/unit/utils/io/file-info.spec.ts +++ b/tests/unit/utils/io/file-info.spec.ts @@ -1,5 +1,6 @@ import { statSync } from "node:fs"; import mockFs from "mock-fs"; +import { zipContent } from "../../../utils/zip-utils"; import { FileInfo, fileEquals, @@ -7,15 +8,18 @@ import { findFilesSync, readAllText, readAllTextSync, + readAllZippedText, readFile, readFileSync, + readZippedFile, } from "@/utils/io/file-info"; -beforeEach(() => { +beforeEach(async () => { mockFs({ "path/to": { "test.txt": "test", "test.json": JSON.stringify({ foo: 42 }), + "test.zip": await zipContent("test", "test.txt"), }, }); }); @@ -281,3 +285,36 @@ describe("readAllTextSync", () => { expect(() => readAllTextSync("path/from/*.txt")).toThrow(/path\/from\/\*\.txt/); }); }); + +describe("readZippedFile", () => { + test("reads the contents of the first matching file", async () => { + const content = await readZippedFile("path/to/*.zip", "test.txt"); + + expect(Buffer.isBuffer(content)).toBe(true); + expect(content.toString()).toEqual("test"); + }); + + test("throws if no files were found", async () => { + await expect(readZippedFile("path/from/*.zip", "")).rejects.toThrow(/path\/from\/\*\.zip/); + }); + + test("throws if the entry does not exist within the zip", async () => { + await expect(readZippedFile("path/to/test.zip", "not-test.txt")).rejects.toThrow(/Entry not found/); + }); +}); + +describe("readAllZippedText", () => { + test("reads the contents of the first matching file", async () => { + const content = await readAllZippedText("path/to/*.zip", "test.txt"); + + expect(content).toEqual("test"); + }); + + test("throws if no files were found", async () => { + await expect(readAllZippedText("path/from/*.zip", "")).rejects.toThrow(/path\/from\/\*\.zip/); + }); + + test("throws if the entry does not exist within the zip", async () => { + await expect(readAllZippedText("path/to/test.zip", "not-test.txt")).rejects.toThrow(/Entry not found/); + }); +});