diff --git a/src/loaders/loader-metadata-reader.ts b/src/loaders/loader-metadata-reader.ts new file mode 100644 index 0000000..3e4c530 --- /dev/null +++ b/src/loaders/loader-metadata-reader.ts @@ -0,0 +1,79 @@ +import { $i } from "@/utils/collections"; +import { PathLike } from "node:fs"; +import { FabricMetadataReader } from "./fabric/fabric-metadata-reader"; +import { ForgeMetadataReader } from "./forge/forge-metadata-reader"; +import { LoaderMetadata } from "./loader-metadata"; +import { LoaderType } from "./loader-type"; +import { QuiltMetadataReader } from "./quilt/quilt-metadata-reader"; + +/** + * Defines a structure for reading metadata files. + * + * @template T - The type of the metadata this reader is able to process. + */ +export interface LoaderMetadataReader { + /** + * Reads the metadata file from a given path. + * + * @param path - The path to the metadata file. + * + * @returns The metadata object, or `undefined` if the file cannot be read. + */ + readMetadataFile(path: PathLike): Promise; +} + +/** + * Combines multiple metadata readers into a single reader + * that tries each reader in order until one successfully reads the metadata. + * + * @param readers - A collection of metadata readers to be combined. + * + * @returns A new metadata reader instance that represents the combined readers. + */ +export function combineLoaderMetadataReaders(readers: Iterable): LoaderMetadataReader { + const readerArray = [...readers]; + + const readMetadataFile = async (path: PathLike) => { + for (const reader of readerArray) { + const metadata = await reader.readMetadataFile(path).catch(() => undefined as LoaderMetadata); + if (metadata) { + return metadata; + } + } + return undefined; + }; + + return { readMetadataFile }; +} + +/** + * Creates a metadata reader for the specified well-known loader. + * + * @param loader - The loader the metadata for which needs to be read. + * + * @returns A metadata reader for the given loader. + */ +export function createLoaderMetadataReader(loader: LoaderType): LoaderMetadataReader { + switch (loader) { + case LoaderType.FABRIC: + return new FabricMetadataReader(); + + case LoaderType.FORGE: + return new ForgeMetadataReader(); + + case LoaderType.QUILT: + return new QuiltMetadataReader(); + + default: + throw new Error(`Unknown mod loader '${LoaderType.format(loader)}'.`); + } +} + +/** + * Creates a metadata reader that is a combination of readers for all known loaders. + * + * @returns A metadata reader that can read metadata from all known loaders. + */ +export function createDefaultLoaderMetadataReader(): LoaderMetadataReader { + return combineLoaderMetadataReaders($i(LoaderType.values()).map(createLoaderMetadataReader)); +} diff --git a/tests/unit/loaders/loader-metadata-reader.spec.ts b/tests/unit/loaders/loader-metadata-reader.spec.ts new file mode 100644 index 0000000..3508154 --- /dev/null +++ b/tests/unit/loaders/loader-metadata-reader.spec.ts @@ -0,0 +1,112 @@ +import { zipFile } from "@/../tests/utils/zip-utils"; +import { LoaderType } from "@/loaders/loader-type"; +import mockFs from "mock-fs"; +import { + LoaderMetadataReader, + combineLoaderMetadataReaders, + createLoaderMetadataReader, + createDefaultLoaderMetadataReader, +} from "@/loaders/loader-metadata-reader"; + +beforeEach(async () => { + mockFs({ + "fabric.jar": await zipFile([__dirname, "../../content/fabric/fabric.mod.json"]), + "quilt.jar": await zipFile([__dirname, "../../content/quilt/quilt.mod.json"]), + "forge.jar": await zipFile([__dirname, "../../content/forge/mods.toml"], "META-INF/mods.toml"), + "text.txt": "", + }); +}); + +afterEach(() => { + mockFs.restore(); +}); + +describe("combineLoaderMetadataReaders", () => { + test("combined reader returns metadata from the first underlying reader that successfully reads the metadata", async () => { + const reader1 = { readMetadataFile: jest.fn().mockImplementation(x => x === "1" ? Promise.resolve({ id: "1" }) : Promise.reject(new Error("Unknown id"))) } as LoaderMetadataReader; + const reader2 = { readMetadataFile: jest.fn().mockImplementation(x => Promise.resolve(x === "2" ? { id: "2" } : undefined)) } as LoaderMetadataReader; + + const combined = combineLoaderMetadataReaders([reader1, reader2]); + + const metadata1 = await combined.readMetadataFile("1"); + expect(metadata1).toEqual({ id: "1" }); + expect(reader1.readMetadataFile).toHaveBeenCalledTimes(1); + expect(reader1.readMetadataFile).toHaveBeenCalledWith("1"); + expect(reader2.readMetadataFile).not.toHaveBeenCalled(); + + const metadata2 = await combined.readMetadataFile("2"); + expect(metadata2).toEqual({ id: "2" }); + expect(reader1.readMetadataFile).toHaveBeenCalledTimes(2); + expect(reader1.readMetadataFile).toHaveBeenCalledWith("2"); + expect(reader2.readMetadataFile).toHaveBeenCalledTimes(1); + expect(reader2.readMetadataFile).toHaveBeenCalledWith("2"); + }); + + test("combined reader returns undefined when no reader can read the metadata", async () => { + const combined = combineLoaderMetadataReaders([]); + const metadata = await combined.readMetadataFile("test"); + + expect(metadata).toBeUndefined(); + }); + + test("combined reader returns undefined instead of throwing", async () => { + const reader1 = { readMetadataFile: jest.fn().mockRejectedValue(new Error("Cannot read the metadata file")) } as LoaderMetadataReader; + + const combined = combineLoaderMetadataReaders([reader1]); + const metadata = await combined.readMetadataFile("test"); + + expect(metadata).toBeUndefined(); + }); +}); + +describe("createLoaderMetadataReader", () => { + test("creates a reader for every known loader", () => { + for (const loader of LoaderType.values()) { + expect(createLoaderMetadataReader(loader)).toBeDefined(); + } + }); + + test("created reader is able to read metadata of its supported loader", async () => { + for (const loader of LoaderType.values()) { + const reader = createLoaderMetadataReader(loader); + const metadata = await reader.readMetadataFile(`${loader}.jar`); + + expect(metadata).toBeDefined(); + expect(metadata.version).toBe("0.1.0"); + } + }); + + test("created reader returns undefined for unsupported metadata files", async () => { + for (const loader of LoaderType.values()) { + const reader = createLoaderMetadataReader(loader); + const metadata = await reader.readMetadataFile("text.txt"); + + expect(metadata).toBeUndefined(); + } + }); + + test("created reader returns undefined for non-existing files", async () => { + for (const loader of LoaderType.values()) { + const reader = createLoaderMetadataReader(loader); + const metadata = await reader.readMetadataFile("text.json"); + + expect(metadata).toBeUndefined(); + } + }); + + test("throws an error when unknown loader is provided", () => { + expect(() => createLoaderMetadataReader("unknown" as LoaderType)).toThrow("Unknown mod loader 'unknown'."); + }); +}); + +describe("createDefaultLoaderMetadataReader", () => { + test("creates a reader that can read metadata from all known loaders", async () => { + const reader = createDefaultLoaderMetadataReader(); + for (const loader of LoaderType.values()) { + const metadata = await reader.readMetadataFile(`${loader}.jar`); + + expect(metadata).toBeDefined(); + expect(metadata.version).toBe("0.1.0"); + } + }); +});