Skip to content

Commit 2c817a9

Browse files
committed
feat(add): accept multiple packages
1 parent ce9d66e commit 2c817a9

9 files changed

Lines changed: 199 additions & 79 deletions

File tree

src/Context.zig

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,16 @@ root: std.Io.Dir,
2828
///
2929
/// Context does not own the writer or its buffer: the caller constructs both
3030
/// and is responsible for flushing before exit. Diagnostics don't belong
31-
/// here — they go to stderr, which this doesn't cover.
31+
/// here — they go to `err`.
3232
out: *std.Io.Writer,
3333

34-
const Context = @This();
34+
/// Buffered stderr, for diagnostics: warnings, per-item failures, anything
35+
/// that isn't the command's actual output. Kept separate from `out` so that
36+
/// callers piping stdout — scripts, agents — get clean data on the pipe with
37+
/// errors still surfacing on the terminal.
38+
///
39+
/// Same ownership rule as `out`: the caller constructs both the writer and its
40+
/// buffer, and is responsible for flushing before exit.
41+
err: *std.Io.Writer,
3542

36-
/// Builds a `Context` from an already-initialized allocator and I/O backend.
37-
/// Takes no ownership of either.
38-
pub fn init(arena: std.mem.Allocator, io: std.Io, root: std.Io.Dir, out: *std.Io.Writer) Context {
39-
return .{ .arena = arena, .io = io, .root = root, .out = out };
40-
}
43+
const Context = @This();

src/cli.zig

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,33 +8,35 @@ const checkCommand = @import("./commands/check.zig");
88
const versionCommand = @import("./commands/version.zig");
99
const version = @import("build_options").version;
1010

11-
pub fn run(ctx: Context, argv: []const [:0]const u8) !void {
11+
pub fn run(ctx: Context, argv: []const [:0]const u8) !u8 {
1212
if (argv.len < 2) return printUsage(ctx);
1313

1414
const a = argv[1];
1515
if (command.isVersionFlag(a)) return printVersion(ctx);
1616
if (command.isHelpFlag(a)) return printUsage(ctx);
1717

1818
const cmd = std.meta.stringToEnum(command.Command, argv[1]) orelse {
19-
try printUsage(ctx);
20-
return error.UnknownCommand;
19+
try ctx.err.writeAll(command.usage);
20+
return 1;
2121
};
2222

23-
switch (cmd) {
23+
return switch (cmd) {
2424
.add => try addCommand.run(ctx, argv),
2525
.list => try listCommand.run(ctx, argv),
2626
.check => try checkCommand.run(ctx, argv),
2727
.version => try versionCommand.run(ctx, argv),
2828
.help => return printUsage(ctx),
29-
}
29+
};
3030
}
3131

32-
fn printUsage(ctx: Context) !void {
32+
fn printUsage(ctx: Context) !u8 {
3333
try ctx.out.writeAll(command.usage);
34+
return 0;
3435
}
3536

36-
fn printVersion(ctx: Context) !void {
37+
fn printVersion(ctx: Context) !u8 {
3738
try ctx.out.writeAll(version ++ "\n");
39+
return 0;
3840
}
3941

4042
test {

src/commands/add.zig

Lines changed: 121 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,53 +5,117 @@ const manifest = @import("../manifest.zig");
55
const git = @import("../git.zig");
66
const fetch = @import("../fetch.zig");
77

8-
/// Adds a dependency to the current project's `build.zig.zon`.
8+
/// A parsed `<repo>[@<version>]` argument.
9+
/// All fields are slices into the original argv string — no allocation.
10+
const Target = struct {
11+
/// Exactly as the user typed it, for diagnostics.
12+
raw: []const u8,
13+
repo: []const u8,
14+
version: ?[]const u8,
15+
16+
const Error = error{ EmptyRepo, EmptyVersion, RangeUnsupported };
17+
18+
/// Split on the last '@', but only when what follows looks like a tag.
19+
/// An ssh-style `git@host:owner/repo` has an '@' too — its tail contains
20+
/// '/' or ':', which a tag never does, so it stays part of the repo.
21+
///
22+
/// Consequence: tags containing '/' (`release/1.0`) are unsupported.
23+
pub fn parse(raw: []const u8) Error!Target {
24+
var repo = raw;
25+
var version: ?[]const u8 = null;
26+
27+
if (std.mem.lastIndexOfScalar(u8, raw, '@')) |i| {
28+
const tail = raw[i + 1 ..];
29+
if (std.mem.indexOfAny(u8, tail, "/:") == null) {
30+
if (tail.len == 0) return error.EmptyVersion;
31+
if (isRange(tail)) return error.RangeUnsupported;
32+
repo = raw[0..i];
33+
version = tail;
34+
}
35+
}
36+
if (repo.len == 0) return error.EmptyRepo;
37+
return .{ .raw = raw, .repo = repo, .version = version };
38+
}
39+
};
40+
41+
/// Adds one or more dependencies to the current project's `build.zig.zon`.
942
///
1043
/// Thin wrapper around `zig fetch --save`, which downloads, hashes, and writes
11-
/// the dependency entry itself. depz builds the `git+https://…` URL — choosing
44+
/// each dependency entry itself. depz builds the `git+https://…` URL — choosing
1245
/// the host from `--registry`, the project's `.depz.registry`, or the default —
1346
/// and lets `zig fetch` resolve and pin the exact commit.
1447
///
1548
/// A trailing `@<tag>` pins a version; without it, the default branch's latest
1649
/// commit is tracked. Range constraints (`^1.0.0`) need upstream tag
1750
/// enumeration to pick a match; that's phase two and is rejected here for now.
18-
pub fn run(ctx: Context, argv: []const []const u8) !void {
51+
///
52+
/// Arguments are fully parsed and validated before any fetch runs, so a bad
53+
/// argument late in the list can't leave a half-written manifest behind.
54+
/// Fetches then run serially — each `--save` rewrites `build.zig.zon` — and a
55+
/// failure is reported without stopping the rest. Returns 1 if any failed.
56+
pub fn run(ctx: Context, argv: []const []const u8) !u8 {
1957
const parsed = try args.classify(ctx.arena, argv[2..]);
2058
if (parsed.positionals.len == 0)
2159
std.process.fatal("`add` needs a package, e.g. `depz add <owner>/<repo>@<version>`", .{});
2260

23-
const src = try ctx.root.readFileAllocOptions(ctx.io, "build.zig.zon", ctx.arena, .unlimited, .of(u8), 0);
24-
const man = try manifest.parse(ctx.arena, src);
25-
const target = parsed.positionals[0];
2661
const alias = parsed.get("as");
27-
const registry = parsed.get("registry");
28-
const host = git.resolveHost(registry, man.depz.registry);
62+
if (alias != null and parsed.positionals.len > 1)
63+
std.process.fatal("--as names a single package, but {d} were given", .{parsed.positionals.len});
2964

30-
// Split repo from optional @version on the LAST '@', so an ssh-style
31-
// git@host:owner/repo keeps its leading git@ in the repo part.
32-
const at = std.mem.lastIndexOfScalar(u8, target, '@');
33-
const repo = if (at) |i| target[0..i] else target;
34-
const version: ?[]const u8 = if (at) |i| target[i + 1 ..] else null;
65+
const targets = try ctx.arena.alloc(Target, parsed.positionals.len);
66+
for (parsed.positionals, 0..) |p, i| {
67+
targets[i] = Target.parse(p) catch |err| {
68+
switch (err) {
69+
error.EmptyRepo => std.process.fatal(
70+
"'{s}': missing package name, expected <owner>/<repo>[@<version>]",
71+
.{p},
72+
),
73+
error.EmptyVersion => std.process.fatal(
74+
"'{s}': trailing '@' with no version — drop it to track the default branch, or write @v1.2.3",
75+
.{p},
76+
),
77+
error.RangeUnsupported => std.process.fatal(
78+
"{s}: needs a concrete version like @v1.2.3, not a range. Range resolution is coming.",
79+
.{p},
80+
),
81+
}
82+
};
83+
// Only catches literally repeated repos. Two distinct repos can still
84+
// resolve to the same package name, but that isn't known until fetch.
85+
for (targets[0..i]) |prev| {
86+
if (std.mem.eql(u8, prev.repo, targets[i].repo))
87+
std.process.fatal("'{s}' listed more than once", .{targets[i].repo});
88+
}
89+
}
3590

36-
const url = if (version) |v| blk: {
37-
if (isRange(v))
38-
std.process.fatal(
39-
"`add` needs a concrete version like @v1.2.3 for now, not a range ('{s}'). Range resolution is coming.",
40-
.{v},
41-
);
42-
break :blk try git.buildGitUrl(ctx.arena, host, repo, v);
43-
} else try git.buildGitUrl(ctx.arena, host, repo, null);
91+
const src = try ctx.root.readFileAllocOptions(ctx.io, "build.zig.zon", ctx.arena, .unlimited, .of(u8), 0);
92+
const man = try manifest.parse(ctx.arena, src);
93+
const host = git.resolveHost(parsed.get("registry"), man.depz.registry);
4494

45-
switch (try fetch.fetchSave(ctx, alias, url)) {
46-
.ok => {},
47-
.name_not_inferable => std.process.fatal(
48-
\\'{s}' has no build.zig.zon, so its name can't be inferred.
49-
\\Re-run with --as=<name> to pick one:
50-
\\ depz add {s} --as=<name>
51-
\\
52-
, .{ target, target }),
53-
.failed => |stderr| std.process.fatal("`zig fetch` failed for {s}:\n{s}", .{ target, stderr }),
95+
// No `fatal` past this point: it skips `defer`, so anything still sitting
96+
// in the stdout buffer — every "added …" line above — would be lost.
97+
var any_failed = false;
98+
for (targets) |t| {
99+
const url = try git.buildGitUrl(ctx.arena, host, t.repo, t.version);
100+
switch (try fetch.fetchSave(ctx, alias, url)) {
101+
.ok => try ctx.out.print("added {s}\n", .{t.raw}),
102+
.name_not_inferable => {
103+
any_failed = true;
104+
try ctx.err.print(
105+
\\'{s}' has no build.zig.zon, so its name can't be inferred.
106+
\\Re-run with --as=<name> to pick one:
107+
\\ depz add {s} --as=<name>
108+
\\
109+
, .{ t.raw, t.raw });
110+
},
111+
.failed => |stderr| {
112+
any_failed = true;
113+
try ctx.err.print("`zig fetch` failed for {s}:\n{s}\n", .{ t.raw, stderr });
114+
},
115+
}
54116
}
117+
118+
return @intFromBool(any_failed);
55119
}
56120

57121
/// True if `v` is a range constraint rather than a concrete version.
@@ -64,10 +128,31 @@ fn isRange(v: []const u8) bool {
64128
};
65129
}
66130

67-
/// Run `zig fetch --save[=<alias>]`; it downloads, hashes, and writes the entry.
68-
fn fetchSave(ctx: Context, alias: ?[]const u8, url: []const u8) !std.process.RunResult {
69-
const save = if (alias) |a| try std.fmt.allocPrint(ctx.arena, "--save={s}", .{a}) else "--save";
70-
return std.process.run(ctx.arena, ctx.io, .{
71-
.argv = &.{ "zig", "fetch", save, url },
72-
});
131+
test "Target.parse: accepted forms" {
132+
const cases = [_]struct {
133+
raw: []const u8,
134+
repo: []const u8,
135+
version: ?[]const u8,
136+
}{
137+
.{ .raw = "foo/bar", .repo = "foo/bar", .version = null },
138+
.{ .raw = "foo/bar@v1.0.0", .repo = "foo/bar", .version = "v1.0.0" },
139+
.{ .raw = "git@github.com:foo/bar", .repo = "git@github.com:foo/bar", .version = null },
140+
.{ .raw = "git@github.com:foo/bar@v1.0.0", .repo = "git@github.com:foo/bar", .version = "v1.0.0" },
141+
};
142+
for (cases) |c| {
143+
const t = try Target.parse(c.raw);
144+
try std.testing.expectEqualStrings(c.raw, t.raw);
145+
try std.testing.expectEqualStrings(c.repo, t.repo);
146+
if (c.version) |v| {
147+
try std.testing.expectEqualStrings(v, t.version.?);
148+
} else {
149+
try std.testing.expect(t.version == null);
150+
}
151+
}
152+
}
153+
154+
test "Target.parse: rejected forms" {
155+
try std.testing.expectError(error.EmptyVersion, Target.parse("foo/bar@"));
156+
try std.testing.expectError(error.EmptyRepo, Target.parse("@v1.0.0"));
157+
try std.testing.expectError(error.RangeUnsupported, Target.parse("foo/bar@^1.0.0"));
73158
}

src/commands/check.zig

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ pub const Row = struct {
3737
}
3838
};
3939

40+
const Tally = struct { updates: usize = 0, failures: usize = 0 };
41+
4042
const Report = struct {
4143
show_all: bool = false,
4244
applied: bool = false,
@@ -51,7 +53,7 @@ const Report = struct {
5153
/// Read-only unless `-u` is passed, which re-fetches everything the check
5254
/// found an update for. Without it, `check` is exactly the dry run of
5355
/// `check -u`.
54-
pub fn run(ctx: Context, argv: []const []const u8) !void {
56+
pub fn run(ctx: Context, argv: []const []const u8) !u8 {
5557
const parsed = try args.classify(ctx.arena, argv[2..]);
5658
const show_all = parsed.has("all");
5759
const apply = parsed.has("u");
@@ -62,7 +64,7 @@ pub fn run(ctx: Context, argv: []const []const u8) !void {
6264

6365
if (man.deps.len == 0) {
6466
try ctx.out.writeAll("No dependencies.\n");
65-
return;
67+
return 0;
6668
}
6769

6870
const plural = if (man.deps.len == 1) "y" else "ies";
@@ -71,11 +73,16 @@ pub fn run(ctx: Context, argv: []const []const u8) !void {
7173
const rows = try gather(ctx, man.deps, target);
7274
try report(ctx.out, rows, .{ .show_all = show_all, .applied = apply });
7375

74-
if (!apply) return;
76+
if (!apply) {
77+
const t = tally(rows);
78+
return @intFromBool(t.updates > 0 or t.failures > 0);
79+
}
7580

76-
const applied = try applyUpdates(ctx, rows);
77-
if (applied > 0)
78-
try ctx.out.print("\nUpdated {d} dependenc{s}.\n", .{ applied, if (applied == 1) "y" else "ies" });
81+
const t = try applyUpdates(ctx, rows);
82+
if (t.updates > 0)
83+
try ctx.out.print("\nUpdated {d} dependenc{s}.\n", .{ t.updates, if (t.updates == 1) "y" else "ies" });
84+
85+
return @intFromBool(t.failures > 0);
7986
}
8087

8188
/// Render the check results as an aligned table, plus a footer for anything
@@ -111,7 +118,7 @@ fn report(w: *std.Io.Writer, rows: []const Row, opts: Report) !void {
111118
if (hidden > 0) {
112119
try w.print("{d} up to date. Run with --all to show {s}.\n", .{ hidden, if (hidden == 1) "it" else "them" });
113120
}
114-
if (!opts.applied) {
121+
if (tally(rows).updates > 0 and !opts.applied) {
115122
try w.writeAll("Run with -u to update.\n");
116123
}
117124
}
@@ -123,21 +130,24 @@ pub fn gather(ctx: Context, deps: []const manifest.Dependency, target: Target) !
123130
return rows;
124131
}
125132

126-
fn applyUpdates(ctx: Context, rows: []const Row) !usize {
127-
var applied: usize = 0;
133+
fn applyUpdates(ctx: Context, rows: []const Row) !Tally {
134+
var t: Tally = .{};
128135
for (rows) |row| {
129136
const up = switch (row.status) {
130137
.update => |u| u,
131138
else => continue,
132139
};
133140
const url = try git.fetchUrl(ctx.arena, up.repo, up.committish);
134141
switch (try fetch.fetchSave(ctx, row.name, url)) {
135-
.ok => applied += 1,
136-
// `-u` always passes an explicit name, so name_not_inferable can't occur.
137-
else => try ctx.out.print(" {s}: update failed\n", .{row.name}),
142+
.ok => t.updates += 1,
143+
.failed => |stderr| {
144+
t.failures += 1;
145+
try ctx.err.print("update failed for {s}:\n{s}\n", .{ row.name, stderr });
146+
},
147+
.name_not_inferable => unreachable,
138148
}
139149
}
140-
return applied;
150+
return t;
141151
}
142152

143153
fn parseTarget(parsed: args.Parsed) !Target {
@@ -228,6 +238,21 @@ fn writeRow(w: *std.Io.Writer, row: Row, name_w: usize, cur_w: usize) !void {
228238
try w.writeByte('\n');
229239
}
230240

241+
/// Actionable outcomes only. A path dep, a no-match, and a failed check are
242+
/// all "not up to date", but none of them is something `-u` can fix.
243+
fn tally(rows: []const Row) Tally {
244+
var t: Tally = .{};
245+
for (rows) |row| {
246+
switch (row.status) {
247+
.update => t.updates += 1,
248+
.failed => t.failures += 1,
249+
else => {},
250+
}
251+
}
252+
253+
return t;
254+
}
255+
231256
/// Append `s` left-aligned in a field of `width`, padding with spaces.
232257
fn padTo(w: *std.Io.Writer, s: []const u8, width: usize) !void {
233258
try w.writeAll(s);

src/commands/list.zig

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const git = @import("../git.zig");
88
///
99
/// Local only — reads the manifest and nothing else. Anything that needs to
1010
/// talk to an upstream lives in `check`.
11-
pub fn run(ctx: Context, argv: []const []const u8) !void {
11+
pub fn run(ctx: Context, argv: []const []const u8) !u8 {
1212
const parsed = try args.classify(ctx.arena, argv[2..]);
1313

1414
// Migration shim: `--check` moved out into its own command.
@@ -21,7 +21,7 @@ pub fn run(ctx: Context, argv: []const []const u8) !void {
2121

2222
if (man.deps.len == 0) {
2323
try ctx.out.writeAll("No dependencies.\n");
24-
return;
24+
return 0;
2525
}
2626

2727
var name_w: usize = 0;
@@ -35,6 +35,8 @@ pub fn run(ctx: Context, argv: []const []const u8) !void {
3535
try padTo(ctx.out, dep.name, name_w + 4);
3636
try ctx.out.print("{s}\n", .{try pinLabel(ctx.arena, dep)});
3737
}
38+
39+
return 0;
3840
}
3941

4042
fn pinLabel(arena: std.mem.Allocator, dep: manifest.Dependency) ![]const u8 {

src/commands/update.zig

Whitespace-only changes.

0 commit comments

Comments
 (0)