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