refactor: workspace restructure — contract package + pure domain ported, tests green
- bun workspaces: contract/ (Schemas = source of truth: config raw↔resolved,
domain DTOs, HttpApi contract, API errors) + plugin/ + ui/
- pure domain moved src/{engine,art}→plugin/src/{domain,art}, types now
imported from @rom-manager/contract and @punktfunk/plugin-kit/wire (the
hand-copied wire.ts dies), loggers injected instead of module-global
- ui.* and devEntry dropped from the config shape (standalone server is
gone; stale keys tolerated on decode, dropped by the next save)
- all 48 domain tests ported and green BEFORE any Effect wiring
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
// The ROM scanner (design §5): walk a ROM root, apply excludes, and reduce the file tree to launchable
|
||||
// candidates — folding multi-disc `.m3u` playlists and `.cue`/`.gdi` sheets so a disc set is ONE entry,
|
||||
// not one per track. The filesystem is injected (`ScanFs`) so the whole thing is unit-testable against
|
||||
// a synthetic tree with zero real I/O.
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as nodePath from "node:path";
|
||||
import type { Platform } from "./platforms.js";
|
||||
|
||||
/** A directory entry the walker sees. */
|
||||
export interface DirEnt {
|
||||
name: string;
|
||||
isDir: boolean;
|
||||
isFile: boolean;
|
||||
}
|
||||
|
||||
/** The filesystem surface the scanner needs — injected for tests. */
|
||||
export interface ScanFs {
|
||||
readDir(dir: string): DirEnt[];
|
||||
readText(file: string): string;
|
||||
}
|
||||
|
||||
/** One launchable candidate produced by the scan. */
|
||||
export interface RomCandidate {
|
||||
platform: string;
|
||||
/** The root directory this file was found under. */
|
||||
rootDir: string;
|
||||
/** Absolute path to the launchable file (the `.m3u`/`.cue`/`.gdi`, or the ROM itself). */
|
||||
absPath: string;
|
||||
/** POSIX path of `absPath` relative to `rootDir` — the `external_id` suffix. */
|
||||
relPath: string;
|
||||
/** Lowercase extension of `absPath` (e.g. `.sfc`). */
|
||||
ext: string;
|
||||
/** Basename of `absPath` without extension — fed to the title parser. */
|
||||
stem: string;
|
||||
/** True for `.zip`/`.7z` — gated on emulator archive support at reconcile time. */
|
||||
isArchive: boolean;
|
||||
}
|
||||
|
||||
const ARCHIVE_EXT = new Set([".zip", ".7z"]);
|
||||
const SHEET_EXT = new Set([".cue", ".gdi", ".ccd", ".mds"]);
|
||||
const TRACK_EXT = new Set([".bin", ".img", ".sub", ".raw"]);
|
||||
|
||||
const lc = (s: string): string => s.toLowerCase();
|
||||
const extOf = (name: string): string => lc(nodePath.extname(name));
|
||||
const stemOf = (name: string): string =>
|
||||
nodePath.basename(name, nodePath.extname(name));
|
||||
const toPosix = (p: string): string => p.split(nodePath.sep).join("/");
|
||||
|
||||
// ---------------------------------------------------------------- glob excludes
|
||||
|
||||
/** Convert a gitignore-ish glob to an anchored regex (`**` = any depth, `*` = within a segment, `?`). */
|
||||
const globToRegex = (glob: string): RegExp => {
|
||||
let re = "";
|
||||
for (let i = 0; i < glob.length; i++) {
|
||||
const c = glob[i];
|
||||
if (c === "*") {
|
||||
if (glob[i + 1] === "*") {
|
||||
re += ".*";
|
||||
i++;
|
||||
if (glob[i + 1] === "/") i++; // `**/` swallows the slash
|
||||
} else {
|
||||
re += "[^/]*";
|
||||
}
|
||||
} else if (c === "?") {
|
||||
re += "[^/]";
|
||||
} else if (c && "\\^$.|+()[]{}".includes(c)) {
|
||||
re += `\\${c}`;
|
||||
} else {
|
||||
re += c;
|
||||
}
|
||||
}
|
||||
return new RegExp(`^${re}$`, "i");
|
||||
};
|
||||
|
||||
/**
|
||||
* gitignore-ish evaluation: patterns are excludes; a `!`-prefixed pattern re-includes. Last match
|
||||
* wins. A bare pattern with no `/` matches the basename at any depth (like gitignore).
|
||||
*/
|
||||
export const makeExcluder = (
|
||||
patterns: string[] | undefined,
|
||||
): ((relPath: string) => boolean) => {
|
||||
if (!patterns || patterns.length === 0) return () => false;
|
||||
const compiled = patterns.map((p) => {
|
||||
const negate = p.startsWith("!");
|
||||
const body = negate ? p.slice(1) : p;
|
||||
const anchored = body.includes("/");
|
||||
return {
|
||||
negate,
|
||||
re: globToRegex(anchored ? body : `**/${body}`),
|
||||
bare: body,
|
||||
};
|
||||
});
|
||||
return (relPath: string): boolean => {
|
||||
let excluded = false;
|
||||
for (const { negate, re } of compiled) {
|
||||
if (re.test(relPath)) excluded = !negate;
|
||||
}
|
||||
return excluded;
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- walk
|
||||
|
||||
interface FileRec {
|
||||
absPath: string;
|
||||
relPath: string; // posix, relative to root
|
||||
dir: string; // absolute dir
|
||||
name: string;
|
||||
ext: string;
|
||||
}
|
||||
|
||||
/** Recursively enumerate files under `rootDir`, applying excludes. Symlinked dirs are not followed. */
|
||||
const walk = (
|
||||
fsx: ScanFs,
|
||||
rootDir: string,
|
||||
exclude: (rel: string) => boolean,
|
||||
): FileRec[] => {
|
||||
const out: FileRec[] = [];
|
||||
const recurse = (dir: string): void => {
|
||||
let ents: DirEnt[];
|
||||
try {
|
||||
ents = fsx.readDir(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const ent of ents) {
|
||||
const abs = nodePath.join(dir, ent.name);
|
||||
const rel = toPosix(nodePath.relative(rootDir, abs));
|
||||
if (ent.isDir) {
|
||||
if (exclude(`${rel}/`)) continue;
|
||||
recurse(abs);
|
||||
} else if (ent.isFile) {
|
||||
if (exclude(rel)) continue;
|
||||
out.push({
|
||||
absPath: abs,
|
||||
relPath: rel,
|
||||
dir,
|
||||
name: ent.name,
|
||||
ext: extOf(ent.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
recurse(rootDir);
|
||||
return out;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- disc folding
|
||||
|
||||
/** Parse the disc/track paths referenced by an `.m3u` (non-comment lines) or a `.cue`/`.gdi` sheet. */
|
||||
const parseReferences = (fsx: ScanFs, file: FileRec): string[] => {
|
||||
let text: string;
|
||||
try {
|
||||
text = fsx.readText(file.absPath);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const refs: string[] = [];
|
||||
if (file.ext === ".m3u") {
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const t = line.trim();
|
||||
if (t && !t.startsWith("#")) refs.push(t);
|
||||
}
|
||||
} else if (
|
||||
file.ext === ".cue" ||
|
||||
file.ext === ".ccd" ||
|
||||
file.ext === ".mds"
|
||||
) {
|
||||
for (const m of text.matchAll(/FILE\s+"([^"]+)"/gi)) {
|
||||
if (m[1]) refs.push(m[1]);
|
||||
}
|
||||
} else if (file.ext === ".gdi") {
|
||||
// GDI: `<track> <lba> <type> <sectorSize> <filename> <offset>` — filename may be quoted.
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = /(?:"([^"]+)"|(\S+\.(?:bin|raw|iso)))/i.exec(line.trim());
|
||||
const f = m?.[1] ?? m?.[2];
|
||||
if (f) refs.push(f);
|
||||
}
|
||||
}
|
||||
// Resolve each reference relative to the sheet's directory, as a posix path key.
|
||||
return refs.map((r) => nodePath.join(file.dir, r));
|
||||
};
|
||||
|
||||
/**
|
||||
* Fold disc images: `.m3u` playlists become one entry (their discs hidden); `.cue`/`.gdi` sheets become
|
||||
* one entry (their tracks hidden); sheets referenced by a playlist are hidden too. Everything else that
|
||||
* matches the platform's extensions is a loose candidate.
|
||||
*/
|
||||
const foldDiscs = (
|
||||
fsx: ScanFs,
|
||||
files: FileRec[],
|
||||
platform: Platform,
|
||||
): FileRec[] => {
|
||||
const referencedByM3u = new Set<string>();
|
||||
const referencedBySheet = new Set<string>();
|
||||
for (const f of files) {
|
||||
if (f.ext === ".m3u") {
|
||||
for (const r of parseReferences(fsx, f)) referencedByM3u.add(r);
|
||||
}
|
||||
}
|
||||
// Parse EVERY sheet for its tracks (even a sheet that a playlist references) so a `.bin` behind a
|
||||
// cue is always hidden — whether the cue stands alone or lives inside an `.m3u`.
|
||||
for (const f of files) {
|
||||
if (SHEET_EXT.has(f.ext)) {
|
||||
for (const r of parseReferences(fsx, f)) referencedBySheet.add(r);
|
||||
}
|
||||
}
|
||||
const extSet = new Set(platform.extensions.map(lc));
|
||||
const out: FileRec[] = [];
|
||||
for (const f of files) {
|
||||
if (referencedByM3u.has(f.absPath)) continue; // disc within a playlist
|
||||
if (referencedBySheet.has(f.absPath)) continue; // track behind a sheet
|
||||
if (f.ext === ".m3u") {
|
||||
out.push(f);
|
||||
continue;
|
||||
}
|
||||
if (SHEET_EXT.has(f.ext)) {
|
||||
out.push(f); // a standalone sheet (its tracks are hidden above)
|
||||
continue;
|
||||
}
|
||||
if (TRACK_EXT.has(f.ext) && !extSet.has(f.ext)) continue; // orphan track, not a platform ROM ext
|
||||
if (ARCHIVE_EXT.has(f.ext) || extSet.has(f.ext)) out.push(f);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- scan
|
||||
|
||||
/** Scan one ROM root into candidates. `platform` is the resolved platform for `rootDir`. */
|
||||
export const scanRoot = (
|
||||
fsx: ScanFs,
|
||||
rootDir: string,
|
||||
platform: Platform,
|
||||
excludes: string[] | undefined,
|
||||
): RomCandidate[] => {
|
||||
const exclude = makeExcluder(excludes);
|
||||
const files = walk(fsx, rootDir, exclude);
|
||||
const extSet = new Set(platform.extensions.map(lc));
|
||||
|
||||
const chosen = platform.disc
|
||||
? foldDiscs(fsx, files, platform)
|
||||
: files.filter((f) => ARCHIVE_EXT.has(f.ext) || extSet.has(f.ext));
|
||||
|
||||
return chosen.map((f) => ({
|
||||
platform: platform.id,
|
||||
rootDir,
|
||||
absPath: f.absPath,
|
||||
relPath: f.relPath,
|
||||
ext: f.ext,
|
||||
stem: stemOf(f.name),
|
||||
isArchive: ARCHIVE_EXT.has(f.ext),
|
||||
}));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- real ScanFs
|
||||
|
||||
/** The production filesystem, backed by node fs. Directory reads are cheap and non-recursive here. */
|
||||
export const realScanFs: ScanFs = {
|
||||
readDir(dir) {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).map((d) => ({
|
||||
name: d.name,
|
||||
isDir: d.isDirectory(),
|
||||
isFile: d.isFile(),
|
||||
}));
|
||||
},
|
||||
readText(file) {
|
||||
return fs.readFileSync(file, "utf8");
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user