feat(cli): add yay/paru-style command parsing
This commit is contained in:
+363
@@ -0,0 +1,363 @@
|
||||
/// tofu.cli — yay-/paru-style command-line parsing.
|
||||
///
|
||||
/// Recognises arch-style operation tokens (`-S`, `-Ss`, `-Syu`, `-R`, `-Si`)
|
||||
/// plus `-h` / `--help`. Flags (`--noconfirm`, `--dry-run`, `--force`,
|
||||
/// `-j<N>`) may appear anywhere in the argument vector.
|
||||
///
|
||||
/// Manual parsing — no framework dependency. Does NOT combine short flags
|
||||
/// beyond the documented exact forms (e.g. `-Syu` is one token, not
|
||||
/// `-S -y -u`).
|
||||
///
|
||||
/// Wired by tasks 20–24 (the individual command implementations).
|
||||
module tofu.cli;
|
||||
|
||||
import std.conv : to, ConvException;
|
||||
import std.string : startsWith, indexOf;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Operation the user requested.
|
||||
enum Command
|
||||
{
|
||||
install, /// -S <pkg>
|
||||
search, /// -Ss <query>
|
||||
upgrade, /// -Syu
|
||||
remove_, /// -R <pkg> (suffixed to avoid keyword clash)
|
||||
info, /// -Si <pkg>
|
||||
help, /// -h / --help
|
||||
}
|
||||
|
||||
/// Parsed command-line state returned by `parseArgs`.
|
||||
struct ParsedArgs
|
||||
{
|
||||
Command cmd;
|
||||
string arg; /// positional argument (package name / search query)
|
||||
bool noconfirm;
|
||||
bool dryRun;
|
||||
bool force;
|
||||
int jobs = 1;
|
||||
}
|
||||
|
||||
// ─── Exception ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Thrown when argument parsing fails.
|
||||
class CliException : Exception
|
||||
{
|
||||
this(string message, string file = __FILE__, size_t line = __LINE__)
|
||||
@safe pure nothrow
|
||||
{
|
||||
super(message, file, line);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Usage text ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Full help text displayed by the `help` command and on parse failures.
|
||||
const string helpText =
|
||||
"tofu — ZUUR package manager\n" ~
|
||||
"Usage: tofu <command> [arg] [flags]\n" ~
|
||||
"Commands:\n" ~
|
||||
" -S <pkg> Install package from ZUUR recipes\n" ~
|
||||
" -Ss <q> Search ZUUR\n" ~
|
||||
" -Syu Upgrade installed packages\n" ~
|
||||
" -R <pkg> Remove package\n" ~
|
||||
" -Si <pkg> Show package info\n" ~
|
||||
" -h, --help Show this help\n" ~
|
||||
"Flags:\n" ~
|
||||
" --noconfirm Skip confirmation prompts\n" ~
|
||||
" --dry-run Show what would happen without doing it\n" ~
|
||||
" --force Overwrite existing builds\n" ~
|
||||
" -j<N> Parallel build jobs\n";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parse a positive integer from `s`. Throws `CliException` if the string
|
||||
/// is not a valid integer or is ≤ 0.
|
||||
private int parsePositiveInt(string s) @safe
|
||||
{
|
||||
int n;
|
||||
try
|
||||
{
|
||||
n = to!int(s);
|
||||
}
|
||||
catch (ConvException)
|
||||
{
|
||||
throw new CliException("invalid jobs count: " ~ s ~ " (must be a positive integer)");
|
||||
}
|
||||
if (n < 1)
|
||||
throw new CliException("invalid jobs count: " ~ s ~ " (must be positive, got " ~ s ~ ")");
|
||||
return n;
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
@safe:
|
||||
|
||||
/// Parse `args` (typically `main`'s argument vector sans argv[0]) into a
|
||||
/// `ParsedArgs` struct.
|
||||
///
|
||||
/// Throws `CliException` on any malformed input. The exception message
|
||||
/// always includes a usage hint.
|
||||
ParsedArgs parseArgs(string[] args)
|
||||
{
|
||||
Command cmd;
|
||||
bool cmdSet = false;
|
||||
string arg;
|
||||
bool noconfirm = false;
|
||||
bool dryRun = false;
|
||||
bool force = false;
|
||||
int jobs = 1;
|
||||
/// Whether the chosen command expects a positional argument.
|
||||
bool cmdTakesArg = false;
|
||||
/// Number of positional args already consumed (max 1).
|
||||
int positionalCount = 0;
|
||||
|
||||
size_t i = 0;
|
||||
while (i < args.length)
|
||||
{
|
||||
string a = args[i];
|
||||
|
||||
// ── flags (can appear anywhere) ──────────────────────────────────
|
||||
if (a == "--noconfirm")
|
||||
{
|
||||
noconfirm = true;
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (a == "--dry-run")
|
||||
{
|
||||
dryRun = true;
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (a == "--force")
|
||||
{
|
||||
force = true;
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (a == "--help" || a == "-h")
|
||||
{
|
||||
cmd = Command.help;
|
||||
cmdSet = true;
|
||||
cmdTakesArg = false;
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── -j flag (can appear anywhere) ────────────────────────────────
|
||||
if (a.startsWith("-j"))
|
||||
{
|
||||
if (a.length > 2)
|
||||
{
|
||||
// -j<N> form
|
||||
jobs = parsePositiveInt(a[2 .. $]);
|
||||
}
|
||||
else
|
||||
{
|
||||
// -j <N> form — consume next token
|
||||
++i;
|
||||
if (i >= args.length)
|
||||
throw new CliException(
|
||||
"missing jobs count after -j (run 'tofu --help')");
|
||||
jobs = parsePositiveInt(args[i]);
|
||||
}
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── command tokens (only the first one wins) ─────────────────────
|
||||
if (!cmdSet)
|
||||
{
|
||||
if (a == "-Syu")
|
||||
{
|
||||
cmd = Command.upgrade;
|
||||
cmdSet = true;
|
||||
cmdTakesArg = false;
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (a == "-Ss")
|
||||
{
|
||||
cmd = Command.search;
|
||||
cmdSet = true;
|
||||
cmdTakesArg = true;
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (a == "-Si")
|
||||
{
|
||||
cmd = Command.info;
|
||||
cmdSet = true;
|
||||
cmdTakesArg = true;
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (a == "-S")
|
||||
{
|
||||
cmd = Command.install;
|
||||
cmdSet = true;
|
||||
cmdTakesArg = true;
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (a == "-R")
|
||||
{
|
||||
cmd = Command.remove_;
|
||||
cmdSet = true;
|
||||
cmdTakesArg = true;
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
// Unknown option before a command was chosen.
|
||||
throw new CliException(
|
||||
"unknown option: " ~ a ~ " (run 'tofu --help')");
|
||||
}
|
||||
|
||||
// ── positional argument (command already chosen) ─────────────────
|
||||
if (cmdTakesArg)
|
||||
{
|
||||
if (positionalCount > 0)
|
||||
throw new CliException(
|
||||
"too many arguments — expected exactly one (run 'tofu --help')");
|
||||
arg = a;
|
||||
++positionalCount;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new CliException(
|
||||
"unexpected argument: " ~ a ~ " (run 'tofu --help')");
|
||||
}
|
||||
++i;
|
||||
}
|
||||
|
||||
// ── post-scan checks ─────────────────────────────────────────────────────
|
||||
if (!cmdSet)
|
||||
throw new CliException("no command given (run 'tofu --help')");
|
||||
|
||||
if (cmdTakesArg && arg.length == 0)
|
||||
throw new CliException(
|
||||
"command requires an argument (run 'tofu --help')");
|
||||
|
||||
return ParsedArgs(cmd, arg, noconfirm, dryRun, force, jobs);
|
||||
}
|
||||
|
||||
// ─── Unittests ────────────────────────────────────────────────────────────────
|
||||
|
||||
@safe:
|
||||
|
||||
version (unittest)
|
||||
{
|
||||
/// Helper: assert that `parseArgs` throws with a message containing `needle`.
|
||||
private void assertThrows(string[] args, string needle) @safe
|
||||
{
|
||||
try
|
||||
{
|
||||
parseArgs(args);
|
||||
assert(false, "expected CliException containing '" ~ needle ~ "'");
|
||||
}
|
||||
catch (CliException e)
|
||||
{
|
||||
assert(e.msg.indexOf(needle) >= 0,
|
||||
"expected message containing '" ~ needle ~ "', got: " ~ e.msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// (1) -S neovim → install, arg=neovim
|
||||
unittest
|
||||
{
|
||||
auto p = parseArgs(["-S", "neovim"]);
|
||||
assert(p.cmd == Command.install);
|
||||
assert(p.arg == "neovim");
|
||||
assert(!p.noconfirm);
|
||||
assert(!p.dryRun);
|
||||
assert(!p.force);
|
||||
assert(p.jobs == 1);
|
||||
}
|
||||
|
||||
/// (2) -Ss editor → search, arg=editor
|
||||
unittest
|
||||
{
|
||||
auto p = parseArgs(["-Ss", "editor"]);
|
||||
assert(p.cmd == Command.search);
|
||||
assert(p.arg == "editor");
|
||||
}
|
||||
|
||||
/// (3) -Syu → upgrade, arg=""
|
||||
unittest
|
||||
{
|
||||
auto p = parseArgs(["-Syu"]);
|
||||
assert(p.cmd == Command.upgrade);
|
||||
assert(p.arg == "");
|
||||
}
|
||||
|
||||
/// (4) -R neovim → remove
|
||||
unittest
|
||||
{
|
||||
auto p = parseArgs(["-R", "neovim"]);
|
||||
assert(p.cmd == Command.remove_);
|
||||
assert(p.arg == "neovim");
|
||||
}
|
||||
|
||||
/// (5) -Si neovim → info
|
||||
unittest
|
||||
{
|
||||
auto p = parseArgs(["-Si", "neovim"]);
|
||||
assert(p.cmd == Command.info);
|
||||
assert(p.arg == "neovim");
|
||||
}
|
||||
|
||||
/// (6) --help and -h → help
|
||||
unittest
|
||||
{
|
||||
auto p1 = parseArgs(["--help"]);
|
||||
assert(p1.cmd == Command.help);
|
||||
assert(p1.arg == "");
|
||||
|
||||
auto p2 = parseArgs(["-h"]);
|
||||
assert(p2.cmd == Command.help);
|
||||
assert(p2.arg == "");
|
||||
}
|
||||
|
||||
/// (7) empty → CliException
|
||||
unittest
|
||||
{
|
||||
assertThrows([], "no command given");
|
||||
}
|
||||
|
||||
/// (8) -S neovim --noconfirm --dry-run -j4 → all flags set
|
||||
unittest
|
||||
{
|
||||
auto p = parseArgs(["-S", "neovim", "--noconfirm", "--dry-run", "-j4"]);
|
||||
assert(p.cmd == Command.install);
|
||||
assert(p.arg == "neovim");
|
||||
assert(p.noconfirm);
|
||||
assert(p.dryRun);
|
||||
assert(!p.force);
|
||||
assert(p.jobs == 4);
|
||||
}
|
||||
|
||||
/// (9) -S neovim -j0 → CliException (positive int required)
|
||||
unittest
|
||||
{
|
||||
assertThrows(["-S", "neovim", "-j0"], "must be positive");
|
||||
}
|
||||
|
||||
/// (10) unknown flag -Z → CliException
|
||||
unittest
|
||||
{
|
||||
assertThrows(["-Z"], "unknown option");
|
||||
}
|
||||
|
||||
/// (11) -S neovim extra (too many positionals) → CliException
|
||||
unittest
|
||||
{
|
||||
assertThrows(["-S", "neovim", "extra"], "too many arguments");
|
||||
}
|
||||
|
||||
/// (12) -Ss without query → CliException
|
||||
unittest
|
||||
{
|
||||
assertThrows(["-Ss"], "command requires an argument");
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
/// tofu.errors — Shared error helpers, exit-code mapping, and lock-file management.
|
||||
///
|
||||
/// Provides:
|
||||
/// - `TofuError` base class with `exitCode` field for direct-exit exceptions.
|
||||
/// - `exitCodeFor(Exception)` — maps every known exception type to the correct
|
||||
/// exit code per the plan's exit-code table.
|
||||
/// - `acquireLock` / `releaseLock` / `isLocked` — PID-based lock file at
|
||||
/// `<cacheDir>/.lock` to prevent concurrent tofu invocations.
|
||||
///
|
||||
/// All public APIs are `@safe`. Filesystem operations are isolated behind
|
||||
/// `@trusted` helpers following the project convention.
|
||||
module tofu.errors;
|
||||
|
||||
import tofu.build : BuildException;
|
||||
import tofu.install : InstallException;
|
||||
import tofu.fetch : FetchException;
|
||||
import tofu.http : HttpException;
|
||||
import tofu.index : IndexException;
|
||||
import tofu.deps : DepException;
|
||||
import tofu.resolve : ResolveException;
|
||||
import tofu.config : ConfigException;
|
||||
import tofu.cli : CliException;
|
||||
|
||||
import std.process : thisProcessID;
|
||||
import std.file : exists, readText, write, remove;
|
||||
import std.path : buildPath;
|
||||
import std.conv : to, ConvException;
|
||||
import std.stdio : stderr;
|
||||
import std.string : indexOf;
|
||||
|
||||
version (Posix) {
|
||||
import core.sys.posix.signal : kill;
|
||||
import core.sys.posix.sys.types : pid_t;
|
||||
import core.stdc.errno : ESRCH, errno;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// TofuError — base for exceptions carrying an explicit exit code
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// An exception that carries its own exit code. Direct callers throw this
|
||||
/// instead of the module-specific exception when they already know the exit
|
||||
/// code; `exitCodeFor` checks for it first.
|
||||
class TofuError : Exception
|
||||
{
|
||||
int exitCode;
|
||||
|
||||
@safe this(int exitCode, string msg, string file = __FILE__,
|
||||
size_t line = __LINE__)
|
||||
{
|
||||
this.exitCode = exitCode;
|
||||
super(msg, file, line);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Exit-code mapping
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Map any exception to the appropriate exit code per the plan's table:
|
||||
///
|
||||
/// | Code | Meaning |
|
||||
/// |------|---------|
|
||||
/// | 0 | Success |
|
||||
/// | 1 | Generic error (invalid args, usage) |
|
||||
/// | 2 | Package not found in ZUUR |
|
||||
/// | 3 | Dependency resolution failure |
|
||||
/// | 4 | Build failure |
|
||||
/// | 5 | Install failure |
|
||||
/// | 6 | Network error |
|
||||
/// | 7 | Config / tool-not-found error |
|
||||
/// | 130 | SIGINT (handled separately in main) |
|
||||
///
|
||||
/// Marker checks on exception fields (`toolMissing`, `notFound`) allow
|
||||
/// distinguishing sub-cases (e.g. tool-missing → 7 vs build failure → 4).
|
||||
@safe int exitCodeFor(Exception e)
|
||||
{
|
||||
// ── TofuError carries its own exit code ─────────────────
|
||||
{
|
||||
auto te = cast(TofuError) e;
|
||||
if (te !is null)
|
||||
return te.exitCode;
|
||||
}
|
||||
|
||||
// ── Build errors ────────────────────────────────────────
|
||||
{
|
||||
auto be = cast(BuildException) e;
|
||||
if (be !is null)
|
||||
return be.toolMissing ? 7 : 4;
|
||||
}
|
||||
|
||||
// ── Install errors ──────────────────────────────────────
|
||||
{
|
||||
auto ie = cast(InstallException) e;
|
||||
if (ie !is null)
|
||||
return ie.toolMissing ? 7 : 5;
|
||||
}
|
||||
|
||||
// ── Fetch errors ────────────────────────────────────────
|
||||
{
|
||||
auto fe = cast(FetchException) e;
|
||||
if (fe !is null)
|
||||
return fe.notFound ? 2 : 6;
|
||||
}
|
||||
|
||||
// ── HTTP / network errors ───────────────────────────────
|
||||
{
|
||||
auto he = cast(HttpException) e;
|
||||
if (he !is null)
|
||||
return 6;
|
||||
}
|
||||
{
|
||||
auto ie = cast(IndexException) e;
|
||||
if (ie !is null)
|
||||
return 6;
|
||||
}
|
||||
|
||||
// ── Resolution errors ───────────────────────────────────
|
||||
{
|
||||
auto de = cast(DepException) e;
|
||||
if (de !is null)
|
||||
return 3;
|
||||
}
|
||||
{
|
||||
auto re = cast(ResolveException) e;
|
||||
if (re !is null)
|
||||
return 3;
|
||||
}
|
||||
|
||||
// ── Config errors ───────────────────────────────────────
|
||||
{
|
||||
auto ce = cast(ConfigException) e;
|
||||
if (ce !is null)
|
||||
return 7;
|
||||
}
|
||||
|
||||
// ── CLI / usage errors ─────────────────────────────────
|
||||
{
|
||||
auto ce = cast(CliException) e;
|
||||
if (ce !is null)
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ── Generic fallback ────────────────────────────────────
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Lock-file exception
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Thrown when another tofu process holds the lock.
|
||||
class LockException : Exception
|
||||
{
|
||||
@safe this(string msg, string file = __FILE__, size_t line = __LINE__)
|
||||
{
|
||||
super(msg, file, line);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Trusted filesystem helpers
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
private @trusted bool lockFileExists(string path)
|
||||
{
|
||||
return exists(path);
|
||||
}
|
||||
|
||||
private @trusted string readLockFile(string path)
|
||||
{
|
||||
return readText(path);
|
||||
}
|
||||
|
||||
private @trusted void writeLockFile(string path, string content)
|
||||
{
|
||||
write(path, content);
|
||||
}
|
||||
|
||||
private @trusted void removeLockFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (exists(path))
|
||||
remove(path);
|
||||
}
|
||||
catch (Exception) {}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// PID-liveness check
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Check whether a PID is alive by sending signal 0.
|
||||
/// Returns `true` if the PID exists, `false` if no such process.
|
||||
version (Posix)
|
||||
private @trusted bool pidAlive(int pid)
|
||||
{
|
||||
errno = 0;
|
||||
int result = kill(cast(pid_t) pid, 0);
|
||||
if (result == 0)
|
||||
return true;
|
||||
if (errno == ESRCH)
|
||||
return false;
|
||||
// Permission error or other — assume alive (conservative)
|
||||
return true;
|
||||
}
|
||||
else
|
||||
private @safe bool pidAlive(int pid)
|
||||
{
|
||||
// Non-POSIX fallback: always assume alive (conservative)
|
||||
return true;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Public lock API
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build the lock-file path for a given cache directory.
|
||||
private @safe string lockPath(string cacheDir)
|
||||
{
|
||||
return buildPath(cacheDir, ".lock");
|
||||
}
|
||||
|
||||
/// Check whether the lock is held by a live process.
|
||||
/// Returns `true` if another tofu instance is running, `false` otherwise.
|
||||
/// If the lock file exists but the PID inside is dead, the lock is considered
|
||||
/// stale — `isLocked` returns `false` and stale detection is handled by
|
||||
/// `acquireLock`.
|
||||
@safe bool isLocked(string cacheDir)
|
||||
{
|
||||
auto path = lockPath(cacheDir);
|
||||
if (!lockFileExists(path))
|
||||
return false;
|
||||
|
||||
string content;
|
||||
try
|
||||
{
|
||||
content = readLockFile(path);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int pid;
|
||||
try
|
||||
{
|
||||
pid = content.to!int;
|
||||
}
|
||||
catch (ConvException)
|
||||
{
|
||||
// Corrupted lock — treat as not locked (acquireLock fixes it)
|
||||
return false;
|
||||
}
|
||||
|
||||
return pidAlive(pid);
|
||||
}
|
||||
|
||||
/// Acquire the tofu lock at `<cacheDir>/.lock`.
|
||||
///
|
||||
/// If the lock is held by a live process → throws `LockException`.
|
||||
/// If the lock is stale (dead PID) → removes it and proceeds.
|
||||
/// If no lock exists → creates one with the current PID.
|
||||
/// Returns `true` on successful acquisition.
|
||||
@safe bool acquireLock(string cacheDir)
|
||||
{
|
||||
import tofu.log : logWarn;
|
||||
|
||||
auto path = lockPath(cacheDir);
|
||||
auto myPid = thisProcessID.to!string;
|
||||
|
||||
if (lockFileExists(path))
|
||||
{
|
||||
string content;
|
||||
try
|
||||
{
|
||||
content = readLockFile(path);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Corrupted lock file — remove and proceed
|
||||
removeLockFile(path);
|
||||
writeLockFile(path, myPid);
|
||||
return true;
|
||||
}
|
||||
|
||||
int pid;
|
||||
try
|
||||
{
|
||||
pid = content.to!int;
|
||||
}
|
||||
catch (ConvException)
|
||||
{
|
||||
// Corrupted lock content — remove and proceed
|
||||
removeLockFile(path);
|
||||
writeLockFile(path, myPid);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!pidAlive(pid))
|
||||
{
|
||||
// Stale lock — the PID is dead
|
||||
logWarn("removing stale lock (PID %d not alive)", pid);
|
||||
removeLockFile(path);
|
||||
writeLockFile(path, myPid);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Live lock — another tofu is running
|
||||
throw new LockException(
|
||||
"another tofu process is running (lock: " ~ path
|
||||
~ ", PID " ~ content ~ ")");
|
||||
}
|
||||
|
||||
// No lock file — create one
|
||||
writeLockFile(path, myPid);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Release the tofu lock by removing the lock file.
|
||||
/// Best-effort — exceptions during removal are silently ignored.
|
||||
@safe void releaseLock(string cacheDir)
|
||||
{
|
||||
removeLockFile(lockPath(cacheDir));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Unittests
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
version (unittest)
|
||||
{
|
||||
import std.file : tempDir, mkdirRecurse, rmdirRecurse;
|
||||
import std.process : thisProcessID;
|
||||
import std.path : buildPath;
|
||||
import std.conv : to;
|
||||
|
||||
/// Create a unique temp directory for lock-file tests.
|
||||
private @trusted string makeCacheDir(string suffix)
|
||||
{
|
||||
auto path = buildPath(tempDir, "tofu-errors-test-" ~ suffix
|
||||
~ "-" ~ thisProcessID.to!string);
|
||||
if (exists(path))
|
||||
rmdirRecurse(path);
|
||||
mkdirRecurse(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
/// Recursively remove a directory, ignoring errors.
|
||||
private @trusted void removeCacheDir(string path)
|
||||
{
|
||||
try { rmdirRecurse(path); } catch (Exception) {}
|
||||
}
|
||||
|
||||
/// Remove a lock artifact, ignoring errors.
|
||||
private @trusted void removeLockArtifact(string cacheDir)
|
||||
{
|
||||
auto p = lockPath(cacheDir);
|
||||
try { if (exists(p)) remove(p); } catch (Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test (1): exitCodeFor — HttpException → 6 ──────────────
|
||||
@safe unittest
|
||||
{
|
||||
auto e = new HttpException("HTTP 404 fetching https://example.com");
|
||||
assert(exitCodeFor(e) == 6);
|
||||
}
|
||||
|
||||
// ── Test (2): exitCodeFor — BuildException → 4 ─────────────
|
||||
@safe unittest
|
||||
{
|
||||
auto e = new BuildException("build failed for foo: error");
|
||||
assert(exitCodeFor(e) == 4);
|
||||
}
|
||||
|
||||
// ── Test (3): exitCodeFor — BuildException(toolMissing) → 7 ─
|
||||
@safe unittest
|
||||
{
|
||||
auto be = new BuildException("zeta-makepkg not found");
|
||||
be.toolMissing = true;
|
||||
assert(exitCodeFor(be) == 7);
|
||||
}
|
||||
|
||||
// ── Test (4): exitCodeFor — InstallException → 5 ───────────
|
||||
@safe unittest
|
||||
{
|
||||
auto e = new InstallException("install failed for foo: error");
|
||||
assert(exitCodeFor(e) == 5);
|
||||
}
|
||||
|
||||
// ── Test (5): exitCodeFor — InstallException(toolMissing) → 7
|
||||
@safe unittest
|
||||
{
|
||||
auto ie = new InstallException("zeta not found");
|
||||
ie.toolMissing = true;
|
||||
assert(exitCodeFor(ie) == 7);
|
||||
}
|
||||
|
||||
// ── Test (6): exitCodeFor — FetchException(notFound) → 2 ───
|
||||
@safe unittest
|
||||
{
|
||||
auto fe = new FetchException("package 'hello' not found in ZUUR recipes");
|
||||
fe.notFound = true;
|
||||
assert(exitCodeFor(fe) == 2);
|
||||
}
|
||||
|
||||
// ── Test (7): exitCodeFor — FetchException(no marker) → 6 ──
|
||||
@safe unittest
|
||||
{
|
||||
auto fe = new FetchException("connection refused");
|
||||
assert(exitCodeFor(fe) == 6);
|
||||
}
|
||||
|
||||
// ── Test (8): exitCodeFor — DepException → 3 ────────────────
|
||||
@safe unittest
|
||||
{
|
||||
auto e = new DepException("dependency cycle: A -> B -> A");
|
||||
assert(exitCodeFor(e) == 3);
|
||||
}
|
||||
|
||||
// ── Test (9): exitCodeFor — ResolveException → 3 ────────────
|
||||
@safe unittest
|
||||
{
|
||||
auto e = new ResolveException(
|
||||
"dependency 'libfoo' not found in ZUUR");
|
||||
assert(exitCodeFor(e) == 3);
|
||||
}
|
||||
|
||||
// ── Test (10): exitCodeFor — ConfigException → 7 ────────────
|
||||
@safe unittest
|
||||
{
|
||||
auto e = new ConfigException("missing required config key");
|
||||
assert(exitCodeFor(e) == 7);
|
||||
}
|
||||
|
||||
// ── Test (11): exitCodeFor — IndexException → 6 ─────────────
|
||||
@safe unittest
|
||||
{
|
||||
auto e = new IndexException("ZUUR index is invalid");
|
||||
assert(exitCodeFor(e) == 6);
|
||||
}
|
||||
|
||||
// ── Test (12): exitCodeFor — TofuError(explicit) bypass ─────
|
||||
@safe unittest
|
||||
{
|
||||
auto te = new TofuError(42, "custom exit");
|
||||
assert(exitCodeFor(te) == 42);
|
||||
// TofuError is also an Exception → exitCodeFor should check it first
|
||||
Exception e = te;
|
||||
assert(exitCodeFor(e) == 42);
|
||||
}
|
||||
|
||||
// ── Test (13): exitCodeFor — generic Exception → 1 ──────────
|
||||
@safe unittest
|
||||
{
|
||||
auto e = new Exception("unknown error");
|
||||
assert(exitCodeFor(e) == 1);
|
||||
}
|
||||
|
||||
// ── Test (14): lock — no lock → acquire succeeds ────────────
|
||||
@safe unittest
|
||||
{
|
||||
auto tmp = makeCacheDir("lock-acquire");
|
||||
scope (exit) {
|
||||
removeLockArtifact(tmp);
|
||||
removeCacheDir(tmp);
|
||||
}
|
||||
|
||||
assert(acquireLock(tmp));
|
||||
assert(lockFileExists(lockPath(tmp)));
|
||||
// Release
|
||||
releaseLock(tmp);
|
||||
assert(!lockFileExists(lockPath(tmp)));
|
||||
}
|
||||
|
||||
// ── Test (15): lock — stale lock (dead PID) → removed ───────
|
||||
@safe unittest
|
||||
{
|
||||
auto tmp = makeCacheDir("lock-stale");
|
||||
scope (exit) {
|
||||
removeLockArtifact(tmp);
|
||||
removeCacheDir(tmp);
|
||||
}
|
||||
|
||||
// Write a lock file with a PID that's almost certainly dead
|
||||
writeLockFile(lockPath(tmp), "99999999");
|
||||
// Acquire should detect the stale lock, remove it, and create a new one
|
||||
assert(acquireLock(tmp));
|
||||
// Read the new lock content
|
||||
auto content = readLockFile(lockPath(tmp));
|
||||
assert(content.to!int != 99999999, "Stale lock should have been replaced");
|
||||
releaseLock(tmp);
|
||||
}
|
||||
|
||||
// ── Test (16): lock — live lock → LockException ─────────────
|
||||
@safe unittest
|
||||
{
|
||||
auto tmp = makeCacheDir("lock-live");
|
||||
scope (exit) {
|
||||
removeLockArtifact(tmp);
|
||||
removeCacheDir(tmp);
|
||||
}
|
||||
|
||||
// Use the current PID to simulate a live lock
|
||||
writeLockFile(lockPath(tmp), thisProcessID.to!string);
|
||||
|
||||
bool caught = false;
|
||||
try
|
||||
{
|
||||
acquireLock(tmp);
|
||||
assert(false, "Expected LockException");
|
||||
}
|
||||
catch (LockException e)
|
||||
{
|
||||
caught = true;
|
||||
assert(e.msg.indexOf("another tofu process is running") >= 0,
|
||||
"Expected 'another tofu process is running', got: " ~ e.msg);
|
||||
assert(e.msg.indexOf(thisProcessID.to!string) >= 0,
|
||||
"Expected PID in message");
|
||||
}
|
||||
assert(caught, "Should have thrown LockException");
|
||||
|
||||
// Clean up manually (our PID owns the lock)
|
||||
releaseLock(tmp);
|
||||
}
|
||||
|
||||
// ── Test (17): lock — corrupted lock content → ignored ──────
|
||||
@safe unittest
|
||||
{
|
||||
auto tmp = makeCacheDir("lock-corrupt");
|
||||
scope (exit) {
|
||||
removeLockArtifact(tmp);
|
||||
removeCacheDir(tmp);
|
||||
}
|
||||
|
||||
writeLockFile(lockPath(tmp), "not-a-number");
|
||||
assert(acquireLock(tmp),
|
||||
"Should acquire lock when lock content is corrupted");
|
||||
releaseLock(tmp);
|
||||
}
|
||||
|
||||
// ── Test (18): lock — isLocked checks liveness ──────────────
|
||||
@safe unittest
|
||||
{
|
||||
auto tmp = makeCacheDir("lock-islocked");
|
||||
scope (exit) {
|
||||
removeLockArtifact(tmp);
|
||||
removeCacheDir(tmp);
|
||||
}
|
||||
|
||||
// No lock file → not locked
|
||||
assert(!isLocked(tmp));
|
||||
|
||||
// Dead PID → not locked
|
||||
writeLockFile(lockPath(tmp), "99999999");
|
||||
assert(!isLocked(tmp));
|
||||
|
||||
// Live PID → locked
|
||||
removeLockArtifact(tmp);
|
||||
writeLockFile(lockPath(tmp), thisProcessID.to!string);
|
||||
assert(isLocked(tmp));
|
||||
|
||||
releaseLock(tmp);
|
||||
}
|
||||
|
||||
// ── Test (19): exitCodeFor — BuildException with stderr details → still 4 ──
|
||||
@safe unittest
|
||||
{
|
||||
auto e = new BuildException("build failed for hello:\nerror: no space left on device");
|
||||
assert(exitCodeFor(e) == 4);
|
||||
}
|
||||
|
||||
// ── Test (20): exitCodeFor — CliException → 1 ──────────────
|
||||
@safe unittest
|
||||
{
|
||||
auto e = new CliException("unknown option: -Z");
|
||||
assert(exitCodeFor(e) == 1);
|
||||
}
|
||||
Reference in New Issue
Block a user