@@ -5,53 +5,117 @@ const manifest = @import("../manifest.zig");
55const git = @import ("../git.zig" );
66const 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}
0 commit comments