Skip to main content

zsfm_hub/
upload.rs

1use std::io::Read;
2use std::path::{Path, PathBuf};
3
4use anyhow::Context;
5use base64::Engine as _;
6use futures_util::StreamExt;
7use indicatif::{ProgressBar, ProgressStyle};
8use sha2::{Digest, Sha256};
9use tokio_util::io::ReaderStream;
10
11const HF_BASE: &str = "https://huggingface.co";
12const PREUPLOAD_SAMPLE: usize = 512;
13
14const SKIP_DIRS: &[&str] = &["target", ".git", "__pycache__", ".venv", "models"];
15
16// HF manages these files automatically — never delete them
17const KEEP_ALWAYS: &[&str] = &[".gitattributes", ".gitignore"];
18
19struct RegularFile {
20    remote: String,
21    content_b64: String,
22}
23
24struct LfsFile {
25    local: PathBuf,
26    remote: String,
27    size: u64,
28    oid: String, // sha256 hex
29}
30
31pub async fn run(repo_id: &str, token: &str, root: &Path) -> anyhow::Result<()> {
32    let client = build_client(token)?;
33
34    ensure_repo(&client, repo_id).await?;
35
36    // List existing repo files so we can delete stale ones in the same commit
37    let existing_files = list_repo_files(&client, repo_id).await?;
38    if !existing_files.is_empty() {
39        println!("  {} file(s) currently in repo", existing_files.len());
40    }
41
42    let files = gather_files(root)?;
43    println!("Gathered {} file(s) to upload …", files.len());
44
45    let (regular, lfs) = classify_files(&client, repo_id, &files).await?;
46    println!("  {} regular, {} LFS", regular.len(), lfs.len());
47
48    if !lfs.is_empty() {
49        upload_lfs(&client, repo_id, &lfs).await?;
50    }
51
52    // Delete anything currently in the repo that we're not re-uploading
53    let upload_paths: std::collections::HashSet<&str> = regular
54        .iter()
55        .map(|f| f.remote.as_str())
56        .chain(lfs.iter().map(|f| f.remote.as_str()))
57        .collect();
58    let to_delete: Vec<String> = existing_files
59        .into_iter()
60        .filter(|p| !upload_paths.contains(p.as_str()) && !KEEP_ALWAYS.contains(&p.as_str()))
61        .collect();
62    if !to_delete.is_empty() {
63        println!("  Deleting {} stale file(s)", to_delete.len());
64    }
65
66    make_commit(&client, repo_id, &regular, &lfs, &to_delete).await?;
67
68    println!("Uploaded → https://huggingface.co/{repo_id}");
69    Ok(())
70}
71
72fn build_client(token: &str) -> anyhow::Result<reqwest::Client> {
73    let mut headers = reqwest::header::HeaderMap::new();
74    headers.insert(
75        reqwest::header::AUTHORIZATION,
76        format!("Bearer {token}")
77            .parse()
78            .context("invalid HF token")?,
79    );
80    headers.insert(
81        reqwest::header::USER_AGENT,
82        reqwest::header::HeaderValue::from_static("zsfm-hub/0.1"),
83    );
84    Ok(reqwest::Client::builder()
85        .default_headers(headers)
86        .build()?)
87}
88
89async fn ensure_repo(client: &reqwest::Client, repo_id: &str) -> anyhow::Result<()> {
90    let name = repo_id
91        .split('/')
92        .nth(1)
93        .context("repo_id must be owner/name")?;
94    let resp: reqwest::Response = client
95        .post(format!("{HF_BASE}/api/repos/create"))
96        .json(&serde_json::json!({ "name": name, "type": "model", "private": false }))
97        .send()
98        .await
99        .context("create repo")?;
100
101    let status = resp.status();
102    if status.is_success() {
103        println!("Created repo {repo_id}");
104    } else if status.as_u16() == 409 {
105        // already exists — fine
106    } else {
107        let body: String = resp.text().await.unwrap_or_default();
108        anyhow::bail!("create repo: HTTP {status}: {body}");
109    }
110    Ok(())
111}
112
113/// List all file (blob) paths currently in the repo, following Link header pagination.
114async fn list_repo_files(client: &reqwest::Client, repo_id: &str) -> anyhow::Result<Vec<String>> {
115    let mut files = Vec::new();
116    let mut url = format!("{HF_BASE}/api/models/{repo_id}/tree/main?recursive=true&limit=1000");
117
118    loop {
119        let resp: reqwest::Response = client.get(&url).send().await.context("list repo tree")?;
120        let status = resp.status();
121        if status.as_u16() == 404 {
122            return Ok(files); // repo is new / empty
123        }
124        if !status.is_success() {
125            let body = resp.text().await.unwrap_or_default();
126            anyhow::bail!("list repo tree: HTTP {status}: {body}");
127        }
128
129        // Extract next-page URL from Link header before consuming body
130        let next_url = resp
131            .headers()
132            .get("link")
133            .and_then(|v| v.to_str().ok())
134            .and_then(parse_next_link);
135
136        #[derive(serde::Deserialize)]
137        struct TreeEntry {
138            r#type: String,
139            path: String,
140        }
141        let entries: Vec<TreeEntry> = resp.json().await.context("parse tree response")?;
142        for e in entries {
143            if e.r#type == "file" {
144                files.push(e.path);
145            }
146        }
147
148        match next_url {
149            Some(next) => url = next,
150            None => break,
151        }
152    }
153
154    Ok(files)
155}
156
157/// Parse `<url>; rel="next"` from a Link header value.
158fn parse_next_link(header: &str) -> Option<String> {
159    for part in header.split(',') {
160        let part = part.trim();
161        if part.contains(r#"rel="next""#) {
162            if let Some(url_part) = part.split(';').next() {
163                let url = url_part
164                    .trim()
165                    .trim_start_matches('<')
166                    .trim_end_matches('>');
167                return Some(url.to_string());
168            }
169        }
170    }
171    None
172}
173
174/// Read at most `PREUPLOAD_SAMPLE` bytes for the HF preupload probe without
175/// loading multi-GB GGUF files into memory.
176fn read_sample(local: &Path) -> anyhow::Result<String> {
177    use std::io::Read;
178    let mut f = std::fs::File::open(local).with_context(|| format!("open {}", local.display()))?;
179    let mut buf = vec![0u8; PREUPLOAD_SAMPLE];
180    let mut n = 0;
181    while n < PREUPLOAD_SAMPLE {
182        let r = f.read(&mut buf[n..])?;
183        if r == 0 {
184            break;
185        }
186        n += r;
187    }
188    Ok(base64::engine::general_purpose::STANDARD.encode(&buf[..n]))
189}
190
191/// Ask HF which files need LFS vs regular inline upload, then prepare both lists.
192async fn classify_files(
193    client: &reqwest::Client,
194    repo_id: &str,
195    files: &[(PathBuf, String)],
196) -> anyhow::Result<(Vec<RegularFile>, Vec<LfsFile>)> {
197    let mut preupload_entries: Vec<serde_json::Value> = Vec::new();
198    for (local, remote) in files {
199        let size = std::fs::metadata(local)
200            .with_context(|| format!("stat {}", local.display()))?
201            .len();
202        let sample = read_sample(local)?;
203        preupload_entries.push(serde_json::json!({
204            "path": remote,
205            "size": size,
206            "sample": sample,
207        }));
208    }
209
210    let url = format!("{HF_BASE}/api/models/{repo_id}/preupload/main");
211    let resp: reqwest::Response = client
212        .post(&url)
213        .json(&serde_json::json!({ "files": preupload_entries }))
214        .send()
215        .await
216        .context("preupload request")?;
217
218    let status = resp.status();
219    if !status.is_success() {
220        let body: String = resp.text().await.unwrap_or_default();
221        anyhow::bail!("preupload: HTTP {status}: {body}");
222    }
223
224    #[derive(serde::Deserialize)]
225    struct PreuploadFile {
226        path: String,
227        #[serde(rename = "uploadMode")]
228        upload_mode: String,
229        #[serde(rename = "shouldIgnore", default)]
230        _should_ignore: bool,
231    }
232    #[derive(serde::Deserialize)]
233    struct PreuploadResp {
234        files: Vec<PreuploadFile>,
235    }
236
237    let preupload: PreuploadResp = resp.json().await.context("parse preupload response")?;
238    let modes: std::collections::HashMap<String, String> = preupload
239        .files
240        .into_iter()
241        .map(|f| (f.path, f.upload_mode))
242        .collect();
243
244    let mut regular: Vec<RegularFile> = Vec::new();
245    let mut lfs: Vec<LfsFile> = Vec::new();
246
247    for (local, remote) in files {
248        let mode = modes.get(remote).map(|s| s.as_str()).unwrap_or("regular");
249        if mode == "lfs" {
250            let size = std::fs::metadata(local)?.len();
251            let oid = sha256_file(local)?;
252            lfs.push(LfsFile {
253                local: local.clone(),
254                remote: remote.clone(),
255                size,
256                oid,
257            });
258        } else {
259            let size = std::fs::metadata(local)
260                .with_context(|| format!("stat {}", local.display()))?
261                .len();
262            anyhow::ensure!(
263                size <= 10 << 20,
264                "{remote}: {size} bytes too large for regular upload (max 10 MiB)"
265            );
266            let bytes =
267                std::fs::read(local).with_context(|| format!("read {}", local.display()))?;
268            let content_b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
269            regular.push(RegularFile {
270                remote: remote.clone(),
271                content_b64,
272            });
273        }
274    }
275
276    Ok((regular, lfs))
277}
278
279fn gather_files(root: &Path) -> anyhow::Result<Vec<(PathBuf, String)>> {
280    let mut out = Vec::new();
281    walk_dir(root, "", &mut out)?;
282
283    // Promote the README.md from the first-level crate subdirectory to the repo root
284    // so it renders on the HF repo page (e.g. "toto-rs/README.md" → "README.md").
285    let has_root_readme = out.iter().any(|(_, r)| r == "README.md");
286    if !has_root_readme {
287        for (_, remote) in &mut out {
288            let parts: Vec<&str> = remote.splitn(3, '/').collect();
289            if parts.len() == 2 && parts[1] == "README.md" {
290                *remote = "README.md".to_string();
291                break;
292            }
293        }
294    }
295
296    Ok(out)
297}
298
299fn walk_dir(dir: &Path, prefix: &str, out: &mut Vec<(PathBuf, String)>) -> anyhow::Result<()> {
300    let mut entries: Vec<_> = std::fs::read_dir(dir)
301        .with_context(|| format!("read_dir {}", dir.display()))?
302        .collect::<Result<_, _>>()?;
303    entries.sort_by_key(|e| e.file_name());
304    for entry in entries {
305        let path = entry.path();
306        let name = entry
307            .file_name()
308            .into_string()
309            .map_err(|_| anyhow::anyhow!("non-UTF-8 filename"))?;
310        if SKIP_DIRS.contains(&name.as_str()) {
311            continue;
312        }
313        let remote = if prefix.is_empty() {
314            name.clone()
315        } else {
316            format!("{prefix}/{name}")
317        };
318        if path.is_file() {
319            out.push((path, remote));
320        } else if path.is_dir() {
321            walk_dir(&path, &remote, out)?;
322        }
323    }
324    Ok(())
325}
326
327fn sha256_file(path: &Path) -> anyhow::Result<String> {
328    let mut f = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
329    let mut hasher = Sha256::new();
330    let mut buf = vec![0u8; 8 * 1024 * 1024];
331    loop {
332        let n = f.read(&mut buf)?;
333        if n == 0 {
334            break;
335        }
336        hasher.update(&buf[..n]);
337    }
338    Ok(format!("{:x}", hasher.finalize()))
339}
340
341async fn upload_lfs(
342    client: &reqwest::Client,
343    repo_id: &str,
344    files: &[LfsFile],
345) -> anyhow::Result<()> {
346    let objects: Vec<_> = files
347        .iter()
348        .map(|f| serde_json::json!({ "oid": f.oid, "size": f.size }))
349        .collect();
350
351    // Request both multipart (for files >5 GB) and basic transfers
352    let url = format!("{HF_BASE}/{repo_id}.git/info/lfs/objects/batch");
353    let lfs_body = serde_json::to_string(&serde_json::json!({
354        "operation": "upload",
355        "transfers": ["multipart", "basic"],
356        "objects": objects,
357    }))?;
358    let resp: reqwest::Response = client
359        .post(&url)
360        .header("Content-Type", "application/vnd.git-lfs+json")
361        .header("Accept", "application/vnd.git-lfs+json")
362        .body(lfs_body)
363        .send()
364        .await
365        .context("LFS batch request")?;
366
367    let status = resp.status();
368    if !status.is_success() {
369        let body: String = resp.text().await.unwrap_or_default();
370        anyhow::bail!("LFS batch: HTTP {status}: {body}");
371    }
372
373    #[derive(serde::Deserialize)]
374    struct BatchResp {
375        objects: Vec<serde_json::Value>,
376    }
377    let batch: BatchResp = resp.json().await.context("parse LFS batch response")?;
378
379    for (file, obj) in files.iter().zip(batch.objects.iter()) {
380        let Some(upload_href) = obj
381            .pointer("/actions/upload/href")
382            .and_then(|v: &serde_json::Value| v.as_str())
383        else {
384            println!("  (already on LFS) {}", file.remote);
385            continue;
386        };
387
388        // Multipart if the server provided chunk_size in the header
389        let is_multipart = obj.pointer("/actions/upload/header/chunk_size").is_some();
390
391        if is_multipart {
392            upload_lfs_object_multipart(file, upload_href, obj)
393                .await
394                .with_context(|| format!("upload (multipart) {}", file.remote))?;
395        } else {
396            upload_lfs_object(file, upload_href, obj)
397                .await
398                .with_context(|| format!("upload {}", file.remote))?;
399        }
400
401        // Verify step (optional but recommended by Git LFS spec)
402        if let Some(verify_href) = obj
403            .pointer("/actions/verify/href")
404            .and_then(|v: &serde_json::Value| v.as_str())
405        {
406            let verify_headers = obj
407                .pointer("/actions/verify/header")
408                .and_then(|v: &serde_json::Value| v.as_object())
409                .cloned()
410                .unwrap_or_default();
411
412            let mut vreq: reqwest::RequestBuilder = reqwest::Client::new()
413                .post(verify_href)
414                .header("Content-Type", "application/vnd.git-lfs+json")
415                .json(&serde_json::json!({ "oid": file.oid, "size": file.size }));
416            for (k, v) in &verify_headers {
417                if let Some(val) = v.as_str() {
418                    vreq = vreq.header(k.as_str(), val);
419                }
420            }
421            let vresp: reqwest::Response = vreq.send().await.context("LFS verify")?;
422            if !vresp.status().is_success() {
423                eprintln!("  Warning: LFS verify returned {}", vresp.status());
424            }
425        }
426    }
427
428    Ok(())
429}
430
431async fn upload_lfs_object(
432    file: &LfsFile,
433    href: &str,
434    obj: &serde_json::Value,
435) -> anyhow::Result<()> {
436    let pb = ProgressBar::new(file.size);
437    pb.set_style(
438        ProgressStyle::with_template(
439            "  {msg} [{bar:40}] {bytes}/{total_bytes} ({bytes_per_sec}, eta {eta})",
440        )
441        .expect("valid progress template")
442        .progress_chars("=>-"),
443    );
444    pb.set_message(file.remote.clone());
445
446    let f = tokio::fs::File::open(&file.local)
447        .await
448        .with_context(|| format!("open {}", file.local.display()))?;
449
450    let pb2 = pb.clone();
451    let stream = ReaderStream::new(f).map(move |chunk| {
452        if let Ok(ref b) = chunk {
453            pb2.inc(b.len() as u64);
454        }
455        chunk
456    });
457
458    // LFS upload goes to S3 / Azure — use a plain client (no HF auth header)
459    let mut req = reqwest::Client::new()
460        .put(href)
461        .header("Content-Length", file.size.to_string());
462
463    if let Some(extra) = obj
464        .pointer("/actions/upload/header")
465        .and_then(|v| v.as_object())
466    {
467        for (k, v) in extra {
468            if let Some(val) = v.as_str() {
469                req = req.header(k.as_str(), val);
470            }
471        }
472    }
473
474    let resp = req
475        .body(reqwest::Body::wrap_stream(stream))
476        .send()
477        .await
478        .context("PUT LFS object")?;
479
480    pb.finish_and_clear();
481
482    let status = resp.status();
483    if !status.is_success() {
484        let body = resp.text().await.unwrap_or_default();
485        anyhow::bail!("LFS PUT HTTP {status}: {body}");
486    }
487
488    Ok(())
489}
490
491async fn upload_lfs_object_multipart(
492    file: &LfsFile,
493    complete_href: &str,
494    obj: &serde_json::Value,
495) -> anyhow::Result<()> {
496    use tokio::io::AsyncReadExt;
497
498    let header = obj
499        .pointer("/actions/upload/header")
500        .and_then(|v| v.as_object())
501        .ok_or_else(|| anyhow::anyhow!("no header in multipart LFS response"))?;
502
503    let chunk_size: usize = header
504        .get("chunk_size")
505        .and_then(|v| v.as_str())
506        .and_then(|s| s.parse().ok())
507        .ok_or_else(|| anyhow::anyhow!("missing chunk_size in multipart header"))?;
508
509    // Collect part URLs sorted numerically by key ("00001", "00002", …)
510    let mut parts: Vec<(u32, String)> = header
511        .iter()
512        .filter_map(|(k, v)| {
513            let n: u32 = k.parse().ok()?;
514            Some((n, v.as_str()?.to_string()))
515        })
516        .collect();
517    parts.sort_by_key(|(n, _)| *n);
518
519    let pb = ProgressBar::new(file.size);
520    pb.set_style(
521        ProgressStyle::with_template(
522            "  {msg} [{bar:40}] {bytes}/{total_bytes} ({bytes_per_sec}, eta {eta})",
523        )
524        .expect("valid progress template")
525        .progress_chars("=>-"),
526    );
527    pb.set_message(file.remote.clone());
528
529    let mut f = tokio::fs::File::open(&file.local)
530        .await
531        .with_context(|| format!("open {}", file.local.display()))?;
532
533    let s3 = reqwest::Client::new(); // plain client — S3 parts use pre-signed URLs
534    let mut etags: Vec<(u32, String)> = Vec::with_capacity(parts.len());
535
536    for (part_num, url) in &parts {
537        // Read up to chunk_size bytes for this part
538        let mut buf = vec![0u8; chunk_size];
539        let mut pos = 0;
540        while pos < chunk_size {
541            let n = f.read(&mut buf[pos..]).await?;
542            if n == 0 {
543                break;
544            }
545            pos += n;
546        }
547        if pos == 0 {
548            break;
549        }
550        buf.truncate(pos);
551        let len = buf.len();
552
553        // Retry up to 3 times on transient connection errors
554        const MAX_RETRIES: usize = 3;
555        let mut last_err: Option<anyhow::Error> = None;
556        let mut etag_opt: Option<String> = None;
557        for attempt in 0..MAX_RETRIES {
558            if attempt > 0 {
559                tokio::time::sleep(std::time::Duration::from_secs(2)).await;
560                eprintln!("  Retrying part {part_num} (attempt {})…", attempt + 1);
561            }
562            match s3
563                .put(url.as_str())
564                .header("Content-Length", len.to_string())
565                .body(buf.clone())
566                .send()
567                .await
568            {
569                Err(e) => {
570                    last_err = Some(anyhow::anyhow!("PUT part {part_num}: {e}"));
571                }
572                Ok(resp) => {
573                    let status = resp.status();
574                    let tag = resp
575                        .headers()
576                        .get("etag")
577                        .and_then(|v| v.to_str().ok())
578                        .map(|s| s.to_string());
579                    if status.is_success() {
580                        if let Some(t) = tag {
581                            etag_opt = Some(t);
582                            last_err = None;
583                            break;
584                        } else {
585                            last_err =
586                                Some(anyhow::anyhow!("PUT part {part_num}: no ETag in response"));
587                        }
588                    } else {
589                        let body = resp.text().await.unwrap_or_default();
590                        last_err = Some(anyhow::anyhow!(
591                            "PUT part {part_num}: HTTP {status}: {body}"
592                        ));
593                    }
594                }
595            }
596        }
597        let etag = etag_opt.ok_or_else(|| {
598            last_err.unwrap_or_else(|| anyhow::anyhow!("PUT part {part_num}: exhausted retries"))
599        })?;
600
601        pb.inc(len as u64);
602        etags.push((*part_num, etag));
603    }
604
605    pb.finish_and_clear();
606
607    // Tell HF to assemble the parts on S3
608    let parts_json: Vec<serde_json::Value> = etags
609        .iter()
610        .map(|(n, e)| serde_json::json!({ "partNumber": n, "etag": e }))
611        .collect();
612
613    let resp = s3
614        .post(complete_href)
615        .json(&serde_json::json!({ "oid": file.oid, "parts": parts_json }))
616        .send()
617        .await
618        .context("complete multipart")?;
619
620    let status = resp.status();
621    if !status.is_success() {
622        let body = resp.text().await.unwrap_or_default();
623        anyhow::bail!("complete multipart: HTTP {status}: {body}");
624    }
625
626    Ok(())
627}
628
629async fn make_commit(
630    client: &reqwest::Client,
631    repo_id: &str,
632    regular: &[RegularFile],
633    lfs: &[LfsFile],
634    to_delete: &[String],
635) -> anyhow::Result<()> {
636    let mut lines: Vec<String> = Vec::new();
637
638    lines.push(serde_json::to_string(&serde_json::json!({
639        "key": "header",
640        "value": { "summary": "Upload model files", "description": "" },
641    }))?);
642
643    for rf in regular {
644        lines.push(serde_json::to_string(&serde_json::json!({
645            "key": "file",
646            "value": {
647                "path": rf.remote,
648                "encoding": "base64",
649                "content": rf.content_b64,
650            },
651        }))?);
652    }
653
654    for lf in lfs {
655        lines.push(serde_json::to_string(&serde_json::json!({
656            "key": "lfsFile",
657            "value": {
658                "path": lf.remote,
659                "algo": "sha256",
660                "oid": lf.oid,
661                "size": lf.size,
662            },
663        }))?);
664    }
665
666    for path in to_delete {
667        lines.push(serde_json::to_string(&serde_json::json!({
668            "key": "deletedEntry",
669            "value": { "path": path },
670        }))?);
671    }
672
673    let body = lines.join("\n");
674
675    let url = format!("{HF_BASE}/api/models/{repo_id}/commit/main");
676    let resp = client
677        .post(&url)
678        .header("Content-Type", "application/x-ndjson")
679        .body(body)
680        .send()
681        .await
682        .context("POST commit")?;
683
684    let status = resp.status();
685    if !status.is_success() {
686        let body = resp.text().await.unwrap_or_default();
687        anyhow::bail!("commit: HTTP {status}: {body}");
688    }
689
690    Ok(())
691}