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/);
+    });
+});