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
16const 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, }
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 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 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, ®ular, &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 } else {
107 let body: String = resp.text().await.unwrap_or_default();
108 anyhow::bail!("create repo: HTTP {status}: {body}");
109 }
110 Ok(())
111}
112
113async 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); }
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 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
157fn 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
174fn 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
191async 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 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 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 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 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 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 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(); let mut etags: Vec<(u32, String)> = Vec::with_capacity(parts.len());
535
536 for (part_num, url) in &parts {
537 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 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 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}