1 /++
2 Download GGUF files from HuggingFace Hub.
3 
4 Without -f, lists .gguf files with sizes.  With -f, downloads with a progress bar.
5 Auth: -t token or HF_TOKEN env var (private repos / higher rate limits).
6 
7 Usage:
8   hf-download -r owner/repo [-f file] [-o outdir] [-t token]
9 +/
10 module hf_download;
11 
12 import std.stdio     : write, writeln, writefln, stderr, stdout;
13 import std.string    : endsWith, indexOf, toStringz;
14 import std.conv      : to;
15 import std.file      : mkdirRecurse, exists, fileWrite = write;
16 import std.path      : buildPath, baseName;
17 import std.net.curl  : HTTP;
18 import std.array     : appender, replicate;
19 import std.exception : enforce;
20 import std.format    : format;
21 import core.stdc.stdio  : fgets, printf, snprintf;
22 import core.stdc.string : strlen;
23 
24 // Listing uses popen+curl; Windows spells these _popen/_pclose.
25 version(Posix)
26 {
27     import core.sys.posix.stdio : popen, pclose;
28 }
29 else version(Windows)
30 {
31     import core.stdc.stdio : FILE;
32     extern(C) nothrow @nogc
33     {
34         FILE* _popen(scope const char* cmd, scope const char* mode);
35         int   _pclose(FILE* stream);
36     }
37     alias popen  = _popen;
38     alias pclose = _pclose;
39 }
40 
41 
42 version(Posix)
43     import core.sys.posix.stdlib : c_getenv = getenv;
44 else version(Windows)
45     extern(C) char* getenv(scope const char*) nothrow @nogc;
46 
47 enum HF_API  = "https://huggingface.co/api";
48 enum HF_BASE = "https://huggingface.co";
49 
50 struct GgufFile
51 {
52     string name;
53     ulong  size; // bytes; 0 if unavailable
54 }
55 
56 int main(string[] args)
57 {
58     struct Options
59     {
60         string repo;
61         string filename;
62         string outDir  = ".";
63         string token;
64         bool   listAll;
65         bool   help;
66     }
67 
68     Options opts;
69     opts.token = envGet("HF_TOKEN");
70 
71     for (int i = 1; i < cast(int) args.length; i++)
72         with (opts) switch (args[i])
73         {
74             case "-r": if (++i < cast(int) args.length) repo     = args[i]; else return printUsage(args[0]); break;
75             case "-f": if (++i < cast(int) args.length) filename = args[i]; else return printUsage(args[0]); break;
76             case "-o": if (++i < cast(int) args.length) outDir   = args[i]; else return printUsage(args[0]); break;
77             case "-t": if (++i < cast(int) args.length) token    = args[i]; else return printUsage(args[0]); break;
78             case "-l", "--list": listAll = true; break;
79             case "-h", "--help": help    = true; break;
80             default:
81                 stderr.writefln("unknown option: %s", args[i]);
82                 return printUsage(args[0]);
83         }
84 
85     if (args.length < 2 || opts.help)
86         return printUsage(args[0]);
87 
88     if (opts.repo.length == 0)
89     {
90         stderr.writeln("error: -r owner/repo is required");
91         return printUsage(args[0]);
92     }
93 
94     if (opts.filename.length == 0 || opts.listAll)
95         return listGgufFiles(opts.repo, opts.token);
96     else
97         return downloadFile(opts.repo, opts.filename, opts.outDir, opts.token);
98 }
99 
100 // ── List ──────────────────────────────────────────────────────────────────────
101 
102 int listGgufFiles(string repo, string token)
103 {
104     // ?blobs=true includes LFS size for each sibling.
105     immutable url = HF_API ~ "/models/" ~ repo ~ "?blobs=true";
106 
107     string body_;
108     try
109         body_ = httpGet(url, token);
110     catch (Exception e)
111     {
112         stderr.writefln("error: %s", e.msg);
113         return 1;
114     }
115 
116     if (hasJsonKey(body_, "error"))
117     {
118         stderr.writefln("API error: %s", extractJsonString(body_, "error"));
119         return 1;
120     }
121     if (!hasJsonKey(body_, "siblings"))
122     {
123         stderr.writeln("error: unexpected API response");
124         return 1;
125     }
126 
127     auto files = extractGgufFiles(body_);
128     insertionSort(files);
129 
130     if (files.length == 0)
131     {
132         writefln("No .gguf files found in %s", repo);
133         return 0;
134     }
135 
136     int maxLen = 0;
137     foreach (ref f; files)
138         if (cast(int) f.name.length > maxLen)
139             maxLen = cast(int) f.name.length;
140 
141     writefln("GGUF files in %s  (%d found)", repo, files.length);
142     printFileList(files, maxLen);
143     writeln();
144     writefln("Download: hf-download -r %s -f <filename>", repo);
145     return 0;
146 }
147 
148 // ── Download ──────────────────────────────────────────────────────────────────
149 
150 int downloadFile(string repo, string filename, string outDir, string token)
151 {
152     immutable url     = HF_BASE ~ "/" ~ repo ~ "/resolve/main/" ~ filename;
153     immutable outPath = buildPath(outDir, baseName(filename));
154 
155     if (outDir != "." && !exists(outDir))
156         mkdirRecurse(outDir);
157 
158     writefln("Downloading: %s", filename);
159     writefln("  from : %s", url);
160     writefln("  to   : %s", outPath);
161     writeln();
162 
163     try
164         download(url, outPath, token);
165     catch (Exception e)
166     {
167         stderr.writefln("error: %s", e.msg);
168         return 1;
169     }
170 
171     writefln("Saved: %s", outPath);
172     return 0;
173 }
174 
175 void download(string url, string fileName, string token = "") @trusted
176 {
177     auto buf = appender!(ubyte[])();
178     size_t contentLength;
179     auto http = HTTP(url);
180     if (token.length)
181         http.addRequestHeader("Authorization", "Bearer " ~ token);
182     http.onReceiveHeader((in k, in v) {
183         if (k == "content-length")
184             contentLength = to!size_t(v);
185     });
186 
187     enum barWidth = 50;
188     http.onReceive((data) {
189         buf.put(data);
190         if (contentLength)
191         {
192             float progress = cast(float) buf.data.length / contentLength;
193             write("\r[",
194                 "=".replicate(cast(int)(barWidth * progress)), ">",
195                 " ".replicate(barWidth - cast(int)(barWidth * progress)),
196                 "] ", format("%d%%", cast(int)(progress * 100)));
197             stdout.flush();
198         }
199         return data.length;
200     });
201 
202     http.perform();
203     enforce(http.statusLine.code / 100 == 2 || http.statusLine.code == 302,
204         format("HTTP request failed: %s", http.statusLine.code));
205     fileWrite(fileName, buf.data);
206     writeln();
207 }
208 
209 // ── HTTP ──────────────────────────────────────────────────────────────────────
210 
211 string httpGet(string url, string token) @trusted
212 {
213     string cmd = "curl -sf -L";
214     if (token.length)
215         cmd ~= " -H \"Authorization: Bearer " ~ token ~ "\"";
216     cmd ~= " \"" ~ url ~ "\"";
217 
218     auto pipe = popen(cmd.toStringz, "r");
219     if (pipe is null)
220         throw new Exception("popen failed");
221 
222     string result;
223     char[4096] tmp = void;
224     while (fgets(tmp.ptr, cast(int) tmp.sizeof, pipe) !is null)
225         result ~= tmp.ptr[0 .. strlen(tmp.ptr)];
226 
227     int rc = pclose(pipe);
228     if (rc != 0)
229         throw new Exception("curl exited with code " ~ rc.to!string);
230 
231     return result;
232 }
233 
234 // ── JSON helpers ──────────────────────────────────────────────────────────────
235 
236 bool hasJsonKey(string json, string key) pure nothrow @safe
237 {
238     return json.indexOf("\"" ~ key ~ "\"") >= 0;
239 }
240 
241 string extractJsonString(string json, string key) pure @safe
242 {
243     auto needle = "\"" ~ key ~ "\"";
244     auto kpos   = json.indexOf(needle);
245     if (kpos < 0) return "";
246     auto pos = kpos + needle.length;
247     while (pos < json.length && (json[pos] == ' ' || json[pos] == ':')) pos++;
248     if (pos >= json.length || json[pos] != '"') return "";
249     pos++;
250     auto end = json.indexOf('"', pos);
251     if (end < 0) return "";
252     return json[pos .. end];
253 }
254 
255 // Parses name + byte size from each sibling in a ?blobs=true API response.
256 GgufFile[] extractGgufFiles(string json) pure @safe
257 {
258     GgufFile[] files;
259     enum rfnKey = "\"rfilename\"";
260     enum szKey  = "\"size\"";
261     ptrdiff_t pos = 0;
262     while (true)
263     {
264         auto kpos = json.indexOf(rfnKey, pos);
265         if (kpos < 0) break;
266         pos = kpos + rfnKey.length;
267 
268         while (pos < json.length &&
269                (json[pos] == ' ' || json[pos] == ':' || json[pos] == '\t'))
270             pos++;
271         if (pos >= json.length || json[pos] != '"') continue;
272         pos++;
273         auto end = json.indexOf('"', pos);
274         if (end < 0) break;
275         auto name = json[pos .. end];
276         pos = end + 1;
277 
278         if (!name.endsWith(".gguf")) continue;
279 
280         // Search for "size" only within this sibling's object, not the next.
281         auto nextRfn  = json.indexOf(rfnKey, pos);
282         auto searchTo = nextRfn < 0 ? cast(ptrdiff_t) json.length : nextRfn;
283 
284         ulong sz  = 0;
285         auto  szp = json.indexOf(szKey, pos);
286         if (szp >= 0 && szp < searchTo)
287         {
288             auto p = szp + szKey.length;
289             while (p < json.length &&
290                    (json[p] == ' ' || json[p] == ':' || json[p] == '\t'))
291                 p++;
292             ulong n = 0;
293             bool  ok = false;
294             while (p < json.length && json[p] >= '0' && json[p] <= '9')
295             {
296                 n  = n * 10 + (json[p] - '0');
297                 p++;
298                 ok = true;
299             }
300             if (ok) sz = n;
301         }
302 
303         files ~= GgufFile(name, sz);
304     }
305     return files;
306 }
307 
308 void insertionSort(GgufFile[] arr) @safe nothrow
309 {
310     for (size_t i = 1; i < arr.length; i++)
311     {
312         GgufFile key = arr[i];
313         ptrdiff_t j  = cast(ptrdiff_t) i - 1;
314         while (j >= 0 && arr[j].name > key.name)
315         {
316             arr[j + 1] = arr[j];
317             j--;
318         }
319         arr[j + 1] = key;
320     }
321 }
322 
323 void printFileList(GgufFile[] files, int maxLen) @trusted nothrow
324 {
325     enum sep = "─";
326     for (int i = 0; i < maxLen + 12; i++) printf("%s", sep.ptr);
327     printf("\n");
328 
329     ulong total = 0;
330     foreach (ref f; files)
331     {
332         total += f.size;
333         if (f.size > 0)
334             printf("  %-*s  %s\n", maxLen, f.name.toStringz, fmtBytes(f.size));
335         else
336             printf("  %s\n", f.name.toStringz);
337     }
338 
339     if (total > 0)
340         printf("  %*s  %s\n", maxLen, "Total".ptr, fmtBytes(total));
341 }
342 
343 // ── Env ───────────────────────────────────────────────────────────────────────
344 
345 string envGet(string name) @trusted nothrow
346 {
347     version(Posix)
348         auto p = c_getenv(name.toStringz);
349     else
350         auto p = getenv(name.toStringz);
351     if (p is null) return "";
352     return cast(string) p[0 .. strlen(p)];
353 }
354 
355 // ── Formatting ────────────────────────────────────────────────────────────────
356 const(char)* fmtBytes(ulong n) @trusted nothrow
357 {
358     static char[32][2] bufs;
359     static int which = 0;
360     which = 1 - which;
361     char* b = bufs[which].ptr;
362 
363     double gb = cast(double) n / (1024.0 * 1024 * 1024);
364     double mb = cast(double) n / (1024.0 * 1024);
365     double kb = cast(double) n / 1024.0;
366 
367     if (n >= 1024UL * 1024 * 1024)
368         snprintf(b, 32, "%.2f GiB", gb);
369     else if (n >= 1024UL * 1024)
370         snprintf(b, 32, "%.2f MiB", mb);
371     else if (n >= 1024)
372         snprintf(b, 32, "%.2f KiB", kb);
373     else
374         snprintf(b, 32, "%llu B", cast(ulong) n);
375 
376     return b;
377 }
378 
379 string humanBytes(ulong n) @trusted nothrow
380 {
381     auto p = fmtBytes(n);
382     return cast(string) p[0 .. strlen(p)];
383 }
384 
385 // ── Usage ─────────────────────────────────────────────────────────────────────
386 
387 int printUsage(string prog) @trusted nothrow
388 {
389     auto p = prog.toStringz;
390     printf(
391         "\nusage: %s -r owner/repo [-f file] [-o outdir] [-t token]\n\n"
392         ~ "  -r   HuggingFace repository  (e.g. unsloth/Qwen3-0.6B-GGUF)\n"
393         ~ "  -f   filename to download    (omit to list .gguf files)\n"
394         ~ "  -o   output directory        (default: current directory)\n"
395         ~ "  -t   HF access token         (or set HF_TOKEN env var)\n"
396         ~ "  -l   list .gguf files and exit\n"
397         ~ "  -h   show this help\n\n"
398         ~ "examples:\n"
399         ~ "  %s -r unsloth/Qwen3-0.6B-GGUF\n"
400         ~ "  %s -r unsloth/Qwen3-0.6B-GGUF -f Qwen3-0.6B-Q4_K_M.gguf -o ~/models\n"
401         ~ "  HF_TOKEN=hf_xxx %s -r org/private-model -f model.gguf\n\n",
402         p, p, p, p);
403     return 1;
404 }