The dotfiles manager.
feat: use github rpc for stars if GITHUB_TOKEN is available
1 file changed, +161 -4
+161-4src/main.rs
| @@ -3,14 +3,20 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; | ||
| 3 | 3 | use clap_complete::{generate, shells}; |
| 4 | 4 | use std::collections::HashMap; |
| 5 | 5 | use comfy_table::{Table, presets::UTF8_BORDERS_ONLY, modifiers::UTF8_ROUND_CORNERS}; |
| 6 | +use std::env; | |
| 7 | +use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; | |
| 8 | +use std::thread; | |
| 9 | +use std::time::Duration; | |
| 6 | 10 | use std::fs; |
| 7 | 11 | use std::io; |
| 12 | +use std::io::Write; | |
| 8 | 13 | use std::path::Path; |
| 9 | 14 | use std::process::Command; |
| 10 | 15 | |
| 11 | 16 | const DOTMAN_DIR: &str = "/usr/local/share/dotman"; |
| 12 | 17 | const DEFAULT_FLEX_URL: &str = |
| 13 | 18 | "https://raw.githubusercontent.com/huncholane/dotman/refs/heads/main/flex.yml"; |
| 19 | +const GH_TOKEN_HELP_URL: &str = "https://github.com/settings/personal-access-tokens"; | |
| 14 | 20 | |
| 15 | 21 | #[derive(Parser)] |
| 16 | 22 | #[command(name = "dotman", about = "Manage dotfile repos and links", version)] |
| @@ -389,13 +395,41 @@ fn cmd_flex(args: FlexArgs) -> Result<()> { | ||
| 389 | 395 | } |
| 390 | 396 | } |
| 391 | 397 | |
| 392 | - // Collect stars (best-effort) | |
| 398 | + // Collect stars efficiently (GraphQL when token present; REST fallback otherwise) | |
| 399 | + let token = env::var("GITHUB_TOKEN").ok(); | |
| 400 | + let mut warn_graphql_failed = false; | |
| 401 | + // Show a spinner during star fetching | |
| 402 | + let spinner_stop = start_spinner("Downloading stars from github.."); | |
| 403 | + | |
| 393 | 404 | let mut detailed: Vec<(String, String, u64)> = Vec::with_capacity(items.len()); |
| 394 | - for (ty, link) in items { | |
| 395 | - let stars = github_stars(&link).unwrap_or(0); | |
| 396 | - detailed.push((ty, link, stars)); | |
| 405 | + if let Some(ref t) = token { | |
| 406 | + let links_only: Vec<String> = items.iter().map(|(_, l)| l.clone()).collect(); | |
| 407 | + match github_stars_batch(&links_only, Some(t.as_str())) { | |
| 408 | + Ok(stars_map) => { | |
| 409 | + for (ty, link) in items { | |
| 410 | + let stars = *stars_map.get(&link).unwrap_or(&0); | |
| 411 | + detailed.push((ty, link, stars)); | |
| 412 | + } | |
| 413 | + } | |
| 414 | + Err(_) => { | |
| 415 | + warn_graphql_failed = true; | |
| 416 | + for (ty, link) in items { | |
| 417 | + let stars = github_stars(&link).unwrap_or(0); | |
| 418 | + detailed.push((ty, link, stars)); | |
| 419 | + } | |
| 420 | + } | |
| 421 | + } | |
| 422 | + } else { | |
| 423 | + for (ty, link) in items { | |
| 424 | + let stars = github_stars(&link).unwrap_or(0); | |
| 425 | + detailed.push((ty, link, stars)); | |
| 426 | + } | |
| 397 | 427 | } |
| 398 | 428 | |
| 429 | + spinner_stop.store(true, Ordering::SeqCst); | |
| 430 | + // Leave the last line in place; print a newline to cleanly end spinner | |
| 431 | + eprintln!(""); | |
| 432 | + | |
| 399 | 433 | // Sort by stars desc |
| 400 | 434 | detailed.sort_by(|a, b| b.2.cmp(&a.2)); |
| 401 | 435 | |
| @@ -412,10 +446,40 @@ fn cmd_flex(args: FlexArgs) -> Result<()> { | ||
| 412 | 446 | } |
| 413 | 447 | |
| 414 | 448 | println!("{}", table); |
| 449 | + if token.is_none() { | |
| 450 | + println!( | |
| 451 | + "\x1b[33mTo improve flex performance, please set your GITHUB_TOKEN environment variable.\nObtain token at {GH_TOKEN_HELP_URL}\x1b[0m" | |
| 452 | + ); | |
| 453 | + } | |
| 454 | + if warn_graphql_failed { | |
| 455 | + println!( | |
| 456 | + "\x1b[33mGITHUB_TOKEN detected but GitHub GraphQL failed; falling back to REST.\nObtain token at {GH_TOKEN_HELP_URL}\x1b[0m" | |
| 457 | + ); | |
| 458 | + } | |
| 415 | 459 | |
| 416 | 460 | Ok(()) |
| 417 | 461 | } |
| 418 | 462 | |
| 463 | +fn start_spinner(message: &str) -> Arc<AtomicBool> { | |
| 464 | + let stop = Arc::new(AtomicBool::new(false)); | |
| 465 | + let stop_clone = Arc::clone(&stop); | |
| 466 | + let msg = message.to_string(); | |
| 467 | + thread::spawn(move || { | |
| 468 | + let frames = ["-", "\\", "|", "/"]; // simple spinner | |
| 469 | + let mut i = 0usize; | |
| 470 | + // Print initial line | |
| 471 | + eprint!("{} {}\r", frames[i % frames.len()], msg); | |
| 472 | + let _ = std::io::stderr().flush(); | |
| 473 | + while !stop_clone.load(Ordering::SeqCst) { | |
| 474 | + i = (i + 1) % frames.len(); | |
| 475 | + eprint!("{} {}\r", frames[i], msg); | |
| 476 | + let _ = std::io::stderr().flush(); | |
| 477 | + thread::sleep(Duration::from_millis(120)); | |
| 478 | + } | |
| 479 | + }); | |
| 480 | + stop | |
| 481 | +} | |
| 482 | + | |
| 419 | 483 | fn fetch_text(url: &str) -> Result<String> { |
| 420 | 484 | let client = reqwest::blocking::Client::builder() |
| 421 | 485 | .user_agent("dotman/0.1") |
| @@ -496,3 +560,96 @@ fn github_stars(link: &str) -> Result<u64> { | ||
| 496 | 560 | .unwrap_or(0); |
| 497 | 561 | Ok(stars) |
| 498 | 562 | } |
| 563 | + | |
| 564 | +fn parse_github_owner_repo(link: &str) -> Option<(String, String)> { | |
| 565 | + let lower = link.to_lowercase(); | |
| 566 | + if !lower.contains("github.com") { | |
| 567 | + return None; | |
| 568 | + } | |
| 569 | + if let Ok(parsed) = url::Url::parse(link) { | |
| 570 | + if parsed.domain().unwrap_or("") != "github.com" { | |
| 571 | + return None; | |
| 572 | + } | |
| 573 | + let mut segs = parsed.path_segments()?; | |
| 574 | + let owner = segs.next()?.to_string(); | |
| 575 | + if let Some(mut repo) = segs.next() { | |
| 576 | + if let Some(stripped) = repo.strip_suffix('.').or_else(|| repo.strip_suffix(".git")) { | |
| 577 | + repo = stripped; | |
| 578 | + } | |
| 579 | + return Some((owner, repo.to_string())); | |
| 580 | + } else { | |
| 581 | + return Some((owner.clone(), owner)); | |
| 582 | + } | |
| 583 | + } else if let Some(rest) = lower.strip_prefix("git@github.com:") { | |
| 584 | + let parts: Vec<&str> = rest.split('/').collect(); | |
| 585 | + if parts.len() >= 2 { | |
| 586 | + let mut repo = parts[1].to_string(); | |
| 587 | + if let Some(stripped) = repo.strip_suffix('.').or_else(|| repo.strip_suffix(".git")) { | |
| 588 | + repo = stripped.to_string(); | |
| 589 | + } | |
| 590 | + return Some((parts[0].to_string(), repo)); | |
| 591 | + } else if parts.len() == 1 { | |
| 592 | + let owner = parts[0].to_string(); | |
| 593 | + return Some((owner.clone(), owner)); | |
| 594 | + } | |
| 595 | + } | |
| 596 | + None | |
| 597 | +} | |
| 598 | + | |
| 599 | +fn github_stars_batch(links: &[String], token: Option<&str>) -> Result<HashMap<String, u64>> { | |
| 600 | + let mut entries: Vec<(String, (String, String))> = Vec::new(); | |
| 601 | + for l in links { | |
| 602 | + if let Some((o, r)) = parse_github_owner_repo(l) { | |
| 603 | + entries.push((l.clone(), (o, r))); | |
| 604 | + } | |
| 605 | + } | |
| 606 | + if entries.is_empty() { | |
| 607 | + return Ok(HashMap::new()); | |
| 608 | + } | |
| 609 | + | |
| 610 | + let client = reqwest::blocking::Client::builder() | |
| 611 | + .user_agent("dotman/0.1") | |
| 612 | + .build() | |
| 613 | + .context("building http client")?; | |
| 614 | + | |
| 615 | + let mut out: HashMap<String, u64> = HashMap::new(); | |
| 616 | + | |
| 617 | + for chunk in entries.chunks(50) { | |
| 618 | + let mut q = String::from("query { "); | |
| 619 | + for (i, (_link, (owner, repo))) in chunk.iter().enumerate() { | |
| 620 | + let alias = format!("r{}", i); | |
| 621 | + let owner_esc = owner.replace('"', "\\\""); | |
| 622 | + let repo_esc = repo.replace('"', "\\\""); | |
| 623 | + q.push_str(&format!( | |
| 624 | + "{}: repository(owner:\"{}\", name:\"{}\") {{ stargazerCount }} ", | |
| 625 | + alias, owner_esc, repo_esc | |
| 626 | + )); | |
| 627 | + } | |
| 628 | + q.push('}'); | |
| 629 | + | |
| 630 | + let mut req = client | |
| 631 | + .post("https://api.github.com/graphql") | |
| 632 | + .json(&serde_json::json!({"query": q})); | |
| 633 | + if let Some(t) = token { | |
| 634 | + req = req.header("Authorization", format!("Bearer {}", t)); | |
| 635 | + } | |
| 636 | + let resp = req.send().context("graphql request failed")?; | |
| 637 | + if !resp.status().is_success() { | |
| 638 | + return Err(anyhow::anyhow!("graphql status {}", resp.status())); | |
| 639 | + } | |
| 640 | + let v: serde_json::Value = resp.json().context("parse graphql json")?; | |
| 641 | + if let Some(data) = v.get("data").and_then(|d| d.as_object()) { | |
| 642 | + for (i, (link, _)) in chunk.iter().enumerate() { | |
| 643 | + let alias = format!("r{}", i); | |
| 644 | + let count = data | |
| 645 | + .get(&alias) | |
| 646 | + .and_then(|obj| obj.get("stargazerCount")) | |
| 647 | + .and_then(|n| n.as_u64()) | |
| 648 | + .unwrap_or(0); | |
| 649 | + out.insert(link.clone(), count); | |
| 650 | + } | |
| 651 | + } | |
| 652 | + } | |
| 653 | + | |
| 654 | + Ok(out) | |
| 655 | +} |