feat(cli): add yay/paru-style command parsing

This commit is contained in:
2026-08-08 18:19:05 -04:00
parent 44024ca37d
commit 47cfdee424
4 changed files with 1371 additions and 0 deletions
+363
View File
@@ -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");
}
+580
View File
@@ -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);
}