feat(types): add core data structures

PackageIndex, Recipe, DepConstraint, BuildPlan, BuildResult, CacheManifest, BinaryCheckResult structs. Pool/BuildSystem/DepOp/Source enums. DepConstraint.parse() ported from ZETA vercmp.lua. TypesException for parsing errors. All @safe, all strings default to empty string.
This commit is contained in:
2026-08-08 17:17:20 -04:00
parent 3fb03b23d7
commit 348f0e3bac
3 changed files with 725 additions and 0 deletions
+541
View File
@@ -0,0 +1,541 @@
/// tofu.types — Core data structures shared across all tofu modules.
///
/// Pure data types with no I/O. All structs are @safe, all strings
/// default to `""` (never null).
module tofu.types;
// ────────────────────────────────────────────────────────────
// Exception
// ────────────────────────────────────────────────────────────
/// Single exception type for the types module.
class TypesException : Exception
{
@safe this(string msg)
{
super(msg);
}
}
// ────────────────────────────────────────────────────────────
// Pool — package source pool
// ────────────────────────────────────────────────────────────
enum Pool
{
binary,
recipes,
both,
}
/// Parse a pool string value.
@safe Pool fromPoolString(string s)
{
switch (s)
{
case "binary":
return Pool.binary;
case "recipes":
return Pool.recipes;
case "both":
return Pool.both;
default:
throw new TypesException("invalid pool value");
}
}
/// Return canonical string representation of a Pool.
@safe string poolToString(Pool p)
{
final switch (p)
{
case Pool.binary:
return "binary";
case Pool.recipes:
return "recipes";
case Pool.both:
return "both";
}
}
// ────────────────────────────────────────────────────────────
// BuildSystem — known build systems
// ────────────────────────────────────────────────────────────
enum BuildSystem
{
autotools,
cmake,
meson,
make,
cargo,
custom,
unknown,
}
/// Map a build-system string to the enum. Unknown strings
/// map to `BuildSystem.unknown` — callers decide how to handle it.
@safe BuildSystem buildSystemFromString(string s)
{
switch (s)
{
case "autotools":
return BuildSystem.autotools;
case "cmake":
return BuildSystem.cmake;
case "meson":
return BuildSystem.meson;
case "make":
return BuildSystem.make;
case "cargo":
return BuildSystem.cargo;
case "custom":
return BuildSystem.custom;
default:
return BuildSystem.unknown;
}
}
// ────────────────────────────────────────────────────────────
// DepOp — dependency-constraint operators
// ────────────────────────────────────────────────────────────
enum DepOp
{
ge, // >=
le, // <=
eq, // == (and = normalised)
ne, // ~=
gt, // >
lt, // <
none, // unconstrained
}
// ────────────────────────────────────────────────────────────
// Source — where a package comes from in a build-plan entry
// ────────────────────────────────────────────────────────────
enum Source
{
recipe,
binary,
}
// ────────────────────────────────────────────────────────────
// Character helpers for dep-spec scanning
// ────────────────────────────────────────────────────────────
private @safe bool isNameChar(char c)
{
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
|| (c >= '0' && c <= '9') || c == '_' || c == '.'
|| c == '+' || c == '-';
}
private @safe bool isWhite(char c)
{
return c == ' ' || c == '\t';
}
// ────────────────────────────────────────────────────────────
// PackageIndex — index.lua entry
// ────────────────────────────────────────────────────────────
struct PackageIndex
{
string name = "";
string ver = "";
string summary = "";
Pool pool = Pool.both;
}
// ────────────────────────────────────────────────────────────
// Recipe — .recipe file fields
// ────────────────────────────────────────────────────────────
struct Recipe
{
string name = "";
string ver = "";
string summary = "";
string url = "";
string sha256 = "";
string[] deps; // raw dep specs e.g. ["libfoo", "libbar>=2.0"]
BuildSystem buildSystem = BuildSystem.unknown;
string[] configureArgs; // extra configure arguments
string buildScript = ""; // required for custom build system
string testCmd = ""; // e.g. "test -f ${DESTDIR}/usr/bin/pkg"
string[] files; // committed file paths
}
// ────────────────────────────────────────────────────────────
// DepConstraint — parsed dependency spec
// ────────────────────────────────────────────────────────────
/// A single parsed dependency constraint.
///
/// Examples:
/// parse("libfoo") → name="libfoo", op=none, ver=""
/// parse("libfoo>=2.0") → name="libfoo", op=ge, ver="2.0"
/// parse("pcre2~=10.42") → name="pcre2", op=ne, ver="10.42"
struct DepConstraint
{
string name = "";
DepOp op = DepOp.none;
string ver = "";
/// Parse a single dependency specification string.
///
/// Semantics ported from `vercmp.parse_dep` in ZETA/lib/vercmp.lua.
/// - name : `[A-Za-z0-9_.+-]+`
/// - ops : `>=` `<=` `==` `~=` `>` `<` `=` (2-char checked first)
/// - `=` normalises to `==`
/// - trailing garbage after version → `TypesException`
/// - empty string / no name → `TypesException`
@safe static DepConstraint parse(string spec)
{
size_t pos = 0;
// ── skip leading whitespace ──
while (pos < spec.length && isWhite(spec[pos]))
pos++;
// ── extract name ──
size_t nameStart = pos;
while (pos < spec.length && isNameChar(spec[pos]))
pos++;
if (pos == nameStart)
throw new TypesException("bad dependency \"" ~ spec ~ "\"");
DepConstraint result;
result.name = spec[nameStart .. pos];
// ── skip whitespace after name ──
while (pos < spec.length && isWhite(spec[pos]))
pos++;
// ── unconstrained: no operator present ──
if (pos >= spec.length)
return result; // op=none, ver=""
// ── try matching an operator (2-char first, then 1-char) ──
string opStr;
if (pos + 1 < spec.length)
{
string two = spec[pos .. pos + 2];
if (two == ">=") { result.op = DepOp.ge; opStr = ">="; }
else if (two == "<=") { result.op = DepOp.le; opStr = "<="; }
else if (two == "==") { result.op = DepOp.eq; opStr = "=="; }
else if (two == "~=") { result.op = DepOp.ne; opStr = "~="; }
}
if (opStr.length == 0)
{
char c = spec[pos];
if (c == '>') { result.op = DepOp.gt; opStr = ">"; }
else if (c == '<') { result.op = DepOp.lt; opStr = "<"; }
else if (c == '=') { result.op = DepOp.eq; opStr = "="; }
}
if (opStr.length == 0)
{
throw new TypesException(
"bad dependency constraint \"" ~ spec
~ "\" (expected NAME OP VERSION)");
}
pos += opStr.length;
// ── skip whitespace after operator ──
while (pos < spec.length && isWhite(spec[pos]))
pos++;
// ── extract version ──
size_t verStart = pos;
while (pos < spec.length && isNameChar(spec[pos]))
pos++;
size_t verEnd = pos;
if (verStart == verEnd)
{
throw new TypesException(
"bad dependency constraint \"" ~ spec
~ "\" (expected NAME OP VERSION)");
}
result.ver = spec[verStart .. verEnd];
// ── check for trailing garbage ──
while (pos < spec.length && isWhite(spec[pos]))
pos++;
if (pos < spec.length)
{
throw new TypesException(
"bad dependency constraint \"" ~ spec
~ "\" (expected NAME OP VERSION)");
}
return result;
}
}
// ────────────────────────────────────────────────────────────
// BuildPlanEntry / BuildPlan
// ────────────────────────────────────────────────────────────
struct BuildPlanEntry
{
string name = "";
string recipePath = "";
Source source = Source.recipe;
}
/// Ordered set of packages to build. Sorting/deps-first ordering
/// is guaranteed by the caller; this is a plain container.
struct BuildPlan
{
BuildPlanEntry[] entries;
@safe void add(string name, string recipePath, Source source)
{
entries ~= BuildPlanEntry(name, recipePath, source);
}
/// Return entries in the order they were added (caller
/// must arrange deps-first ordering before calling this).
@safe BuildPlanEntry[] order()
{
return entries;
}
@safe bool isEmpty()
{
return entries.length == 0;
}
}
// ────────────────────────────────────────────────────────────
// CacheManifest — cached-package metadata
// ────────────────────────────────────────────────────────────
struct CacheManifest
{
string name = "";
string ver = "";
long fetchedAt = 0; // unix timestamp
}
// ────────────────────────────────────────────────────────────
// BuildFailure — single build failure record
// ────────────────────────────────────────────────────────────
struct BuildFailure
{
string name = "";
string reason = "";
}
// ────────────────────────────────────────────────────────────
// BuildResult — build outcome tracker
// ────────────────────────────────────────────────────────────
struct BuildResult
{
string[] succeeded;
BuildFailure[] failed;
/// Return just the names of the packages that failed.
@safe string[] failedNames()
{
string[] names;
foreach (f; failed)
names ~= f.name;
return names;
}
}
// ────────────────────────────────────────────────────────────
// BinaryCheckResult — pre-built binary availability
// ────────────────────────────────────────────────────────────
struct BinaryCheckResult
{
bool exists = false;
string ver = "";
bool satisfies = false;
}
// ────────────────────────────────────────────────────────────
// Unittests
// ────────────────────────────────────────────────────────────
@safe unittest
{
// ── Pool parsing ──────────────────────────────────────
assert(fromPoolString("binary") == Pool.binary);
assert(fromPoolString("recipes") == Pool.recipes);
assert(fromPoolString("both") == Pool.both);
assert(poolToString(Pool.binary) == "binary");
assert(poolToString(Pool.recipes) == "recipes");
assert(poolToString(Pool.both) == "both");
bool caught = false;
try
{
fromPoolString("nope");
assert(false, "expected exception");
}
catch (TypesException e)
{
caught = true;
}
assert(caught, "invalid pool should throw");
}
@safe unittest
{
// ── DepConstraint: unconstrained ─────────────────────
{
auto d = DepConstraint.parse("libfoo");
assert(d.name == "libfoo");
assert(d.op == DepOp.none);
assert(d.ver == "");
}
{
auto d = DepConstraint.parse("pcre2");
assert(d.name == "pcre2");
assert(d.op == DepOp.none);
assert(d.ver == "");
}
}
@safe unittest
{
// ── DepConstraint: all operators ─────────────────────
{
auto d = DepConstraint.parse("libfoo>=2.0");
assert(d.name == "libfoo");
assert(d.op == DepOp.ge);
assert(d.ver == "2.0");
}
{
auto d = DepConstraint.parse("libfoo<=2.0");
assert(d.name == "libfoo");
assert(d.op == DepOp.le);
assert(d.ver == "2.0");
}
{
auto d = DepConstraint.parse("libfoo==2.0");
assert(d.name == "libfoo");
assert(d.op == DepOp.eq);
assert(d.ver == "2.0");
}
{
auto d = DepConstraint.parse("libfoo~=2.0");
assert(d.name == "libfoo");
assert(d.op == DepOp.ne);
assert(d.ver == "2.0");
}
{
auto d = DepConstraint.parse("libfoo>2.0");
assert(d.name == "libfoo");
assert(d.op == DepOp.gt);
assert(d.ver == "2.0");
}
{
auto d = DepConstraint.parse("libfoo<2.0");
assert(d.name == "libfoo");
assert(d.op == DepOp.lt);
assert(d.ver == "2.0");
}
{
// single = normalises to ==
auto d = DepConstraint.parse("libfoo=2.0");
assert(d.name == "libfoo");
assert(d.op == DepOp.eq);
assert(d.ver == "2.0");
}
}
@safe unittest
{
// ── DepConstraint: edge cases ────────────────────────
// empty string → throw
bool caught = false;
try
{
DepConstraint.parse("");
assert(false, "expected exception");
}
catch (TypesException)
{
caught = true;
}
assert(caught, "empty string should throw");
// whitespace-only → throw
caught = false;
try
{
DepConstraint.parse(" ");
assert(false, "expected exception");
}
catch (TypesException)
{
caught = true;
}
assert(caught, "whitespace-only should throw");
// trailing garbage
caught = false;
try
{
DepConstraint.parse("libfoo>=2.0 extra");
assert(false, "expected exception");
}
catch (TypesException)
{
caught = true;
}
assert(caught, "trailing garbage should throw");
}
@safe unittest
{
// ── BuildPlan ────────────────────────────────────────
BuildPlan bp;
assert(bp.isEmpty());
bp.add("mypkg", "path/to/mypkg.recipe", Source.recipe);
assert(!bp.isEmpty());
bp.add("libbar", "path/to/libbar.recipe", Source.recipe);
auto ordered = bp.order();
assert(ordered.length == 2);
assert(ordered[0].name == "mypkg");
assert(ordered[0].recipePath == "path/to/mypkg.recipe");
assert(ordered[0].source == Source.recipe);
assert(ordered[1].name == "libbar");
assert(ordered[1].recipePath == "path/to/libbar.recipe");
assert(ordered[1].source == Source.recipe);
}
@safe unittest
{
// ── BuildResult.failedNames ──────────────────────────
BuildResult br;
assert(br.failedNames().length == 0);
br.failed ~= BuildFailure("foo", "compile error");
br.failed ~= BuildFailure("bar", "link error");
br.succeeded ~= "baz";
auto names = br.failedNames();
assert(names.length == 2);
assert(names[0] == "foo");
assert(names[1] == "bar");
}