The dotfiles manager.
style: better looking flex
1 file changed, +97 -60
+97-60src/main.rs
| @@ -1,14 +1,15 @@ | ||
| 1 | 1 | use anyhow::{bail, Context, Result}; |
| 2 | 2 | use clap::{Args, Parser, Subcommand, ValueEnum}; |
| 3 | 3 | use clap_complete::{generate, shells}; |
| 4 | +use std::collections::HashMap; | |
| 4 | 5 | use std::fs; |
| 5 | 6 | use std::io; |
| 6 | 7 | use std::path::Path; |
| 7 | 8 | use std::process::Command; |
| 8 | -use std::collections::HashMap; | |
| 9 | 9 | |
| 10 | 10 | const DOTMAN_DIR: &str = "/usr/local/share/dotman"; |
| 11 | -const DEFAULT_FLEX_URL: &str = "https://raw.githubusercontent.com/huncholane/dotman/refs/heads/main/flex.yml"; | |
| 11 | +const DEFAULT_FLEX_URL: &str = | |
| 12 | + "https://raw.githubusercontent.com/huncholane/dotman/refs/heads/main/flex.yml"; | |
| 12 | 13 | |
| 13 | 14 | #[derive(Parser)] |
| 14 | 15 | #[command(name = "dotman", about = "Manage dotfile repos and links", version)] |
| @@ -93,13 +94,8 @@ fn ensure_dotman_dir() -> Result<()> { | ||
| 93 | 94 | } |
| 94 | 95 | |
| 95 | 96 | fn derive_repo_name(repo_url: &str) -> String { |
| 96 | - let trimmed = repo_url.trim_end_matches('/') | |
| 97 | - .trim_end_matches(".git"); | |
| 98 | - trimmed | |
| 99 | - .rsplit('/') | |
| 100 | - .next() | |
| 101 | - .unwrap_or(trimmed) | |
| 102 | - .to_string() | |
| 97 | + let trimmed = repo_url.trim_end_matches('/').trim_end_matches(".git"); | |
| 98 | + trimmed.rsplit('/').next().unwrap_or(trimmed).to_string() | |
| 103 | 99 | } |
| 104 | 100 | |
| 105 | 101 | fn cmd_install(repo: &str) -> Result<()> { |
| @@ -160,37 +156,36 @@ fn cmd_link(name: &str, target_name: &str) -> Result<()> { | ||
| 160 | 156 | // Create symlink |
| 161 | 157 | #[cfg(unix)] |
| 162 | 158 | { |
| 163 | - std::os::unix::fs::symlink(&source, &target) | |
| 164 | - .with_context(|| format!( | |
| 159 | + std::os::unix::fs::symlink(&source, &target).with_context(|| { | |
| 160 | + format!( | |
| 165 | 161 | "Failed creating symlink {} -> {}", |
| 166 | 162 | target.display(), |
| 167 | 163 | source.display() |
| 168 | - ))?; | |
| 164 | + ) | |
| 165 | + })?; | |
| 169 | 166 | } |
| 170 | 167 | #[cfg(windows)] |
| 171 | 168 | { |
| 172 | 169 | if source.is_dir() { |
| 173 | - std::os::windows::fs::symlink_dir(&source, &target) | |
| 174 | - .with_context(|| format!( | |
| 170 | + std::os::windows::fs::symlink_dir(&source, &target).with_context(|| { | |
| 171 | + format!( | |
| 175 | 172 | "Failed creating symlink {} -> {}", |
| 176 | 173 | target.display(), |
| 177 | 174 | source.display() |
| 178 | - ))?; | |
| 175 | + ) | |
| 176 | + })?; | |
| 179 | 177 | } else { |
| 180 | - std::os::windows::fs::symlink_file(&source, &target) | |
| 181 | - .with_context(|| format!( | |
| 178 | + std::os::windows::fs::symlink_file(&source, &target).with_context(|| { | |
| 179 | + format!( | |
| 182 | 180 | "Failed creating symlink {} -> {}", |
| 183 | 181 | target.display(), |
| 184 | 182 | source.display() |
| 185 | - ))?; | |
| 183 | + ) | |
| 184 | + })?; | |
| 186 | 185 | } |
| 187 | 186 | } |
| 188 | 187 | |
| 189 | - println!( | |
| 190 | - "Linked {} -> {}", | |
| 191 | - source.display(), | |
| 192 | - target.display() | |
| 193 | - ); | |
| 188 | + println!("Linked {} -> {}", source.display(), target.display()); | |
| 194 | 189 | Ok(()) |
| 195 | 190 | } |
| 196 | 191 | |
| @@ -223,7 +218,11 @@ fn cmd_update() -> Result<()> { | ||
| 223 | 218 | if status.success() { |
| 224 | 219 | updated += 1; |
| 225 | 220 | } else { |
| 226 | - eprintln!("git pull failed in {} with status {}", path.display(), status); | |
| 221 | + eprintln!( | |
| 222 | + "git pull failed in {} with status {}", | |
| 223 | + path.display(), | |
| 224 | + status | |
| 225 | + ); | |
| 227 | 226 | } |
| 228 | 227 | } |
| 229 | 228 | |
| @@ -257,10 +256,11 @@ fn remove_path(path: &Path) -> Result<()> { | ||
| 257 | 256 | Ok(md) if md.file_type().is_symlink() => { |
| 258 | 257 | fs::remove_file(path).with_context(|| format!("Removing symlink {}", path.display())) |
| 259 | 258 | } |
| 260 | - Ok(md) if md.is_dir() => { | |
| 261 | - fs::remove_dir_all(path).with_context(|| format!("Removing directory {}", path.display())) | |
| 259 | + Ok(md) if md.is_dir() => fs::remove_dir_all(path) | |
| 260 | + .with_context(|| format!("Removing directory {}", path.display())), | |
| 261 | + Ok(_md) => { | |
| 262 | + fs::remove_file(path).with_context(|| format!("Removing file {}", path.display())) | |
| 262 | 263 | } |
| 263 | - Ok(_md) => fs::remove_file(path).with_context(|| format!("Removing file {}", path.display())), | |
| 264 | 264 | Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), |
| 265 | 265 | Err(e) => Err(e).with_context(|| format!("Accessing {}", path.display())), |
| 266 | 266 | } |
| @@ -275,8 +275,8 @@ fn cmd_active() -> Result<()> { | ||
| 275 | 275 | } |
| 276 | 276 | |
| 277 | 277 | let mut found = Vec::new(); |
| 278 | - for entry in fs::read_dir(&config_dir) | |
| 279 | - .with_context(|| format!("Reading {}", config_dir.display()))? | |
| 278 | + for entry in | |
| 279 | + fs::read_dir(&config_dir).with_context(|| format!("Reading {}", config_dir.display()))? | |
| 280 | 280 | { |
| 281 | 281 | let entry = entry?; |
| 282 | 282 | let path = entry.path(); |
| @@ -330,7 +330,9 @@ fn cmd_list() -> Result<()> { | ||
| 330 | 330 | for entry in fs::read_dir(root).with_context(|| format!("Reading {}", DOTMAN_DIR))? { |
| 331 | 331 | let entry = entry?; |
| 332 | 332 | let path = entry.path(); |
| 333 | - if !path.is_dir() { continue; } | |
| 333 | + if !path.is_dir() { | |
| 334 | + continue; | |
| 335 | + } | |
| 334 | 336 | let name = match path.file_name().and_then(|s| s.to_str()) { |
| 335 | 337 | Some(s) => s.to_string(), |
| 336 | 338 | None => continue, |
| @@ -341,7 +343,9 @@ fn cmd_list() -> Result<()> { | ||
| 341 | 343 | if repos.is_empty() { |
| 342 | 344 | println!("No repositories installed in {}.", DOTMAN_DIR); |
| 343 | 345 | } else { |
| 344 | - for r in repos { println!("{}", r); } | |
| 346 | + for r in repos { | |
| 347 | + println!("{}", r); | |
| 348 | + } | |
| 345 | 349 | } |
| 346 | 350 | Ok(()) |
| 347 | 351 | } |
| @@ -363,8 +367,8 @@ fn cmd_flex(args: FlexArgs) -> Result<()> { | ||
| 363 | 367 | } |
| 364 | 368 | }; |
| 365 | 369 | |
| 366 | - let map: HashMap<String, FlexEntry> = serde_yaml::from_str(&yaml) | |
| 367 | - .context("Parsing YAML for flex")?; | |
| 370 | + let map: HashMap<String, FlexEntry> = | |
| 371 | + serde_yaml::from_str(&yaml).context("Parsing YAML for flex")?; | |
| 368 | 372 | |
| 369 | 373 | let filters: Vec<String> = args.types.iter().map(|s| s.to_lowercase()).collect(); |
| 370 | 374 | |
| @@ -377,7 +381,9 @@ fn cmd_flex(args: FlexArgs) -> Result<()> { | ||
| 377 | 381 | match entry { |
| 378 | 382 | FlexEntry::Single(u) => items.push((ty.clone(), u)), |
| 379 | 383 | FlexEntry::Many(v) => { |
| 380 | - for u in v { items.push((ty.clone(), u)); } | |
| 384 | + for u in v { | |
| 385 | + items.push((ty.clone(), u)); | |
| 386 | + } | |
| 381 | 387 | } |
| 382 | 388 | } |
| 383 | 389 | } |
| @@ -393,11 +399,9 @@ fn cmd_flex(args: FlexArgs) -> Result<()> { | ||
| 393 | 399 | detailed.sort_by(|a, b| b.2.cmp(&a.2)); |
| 394 | 400 | |
| 395 | 401 | for (ty, link, stars) in detailed { |
| 396 | - if stars > 0 { | |
| 397 | - println!("{} | {} ⭐ -> {}", ty, stars, link); | |
| 398 | - } else { | |
| 399 | - println!("{} | - -> {}", ty, link); | |
| 400 | - } | |
| 402 | + // Fixed widths: type left-10, stars right-10 | |
| 403 | + let star_text = format!("{} ", stars); | |
| 404 | + println!("{:<10}|{:>10} -> {}", ty, star_text, link); | |
| 401 | 405 | } |
| 402 | 406 | |
| 403 | 407 | Ok(()) |
| @@ -408,7 +412,10 @@ fn fetch_text(url: &str) -> Result<String> { | ||
| 408 | 412 | .user_agent("dotman/0.1") |
| 409 | 413 | .build() |
| 410 | 414 | .context("building http client")?; |
| 411 | - let resp = client.get(url).send().with_context(|| format!("GET {}", url))?; | |
| 415 | + let resp = client | |
| 416 | + .get(url) | |
| 417 | + .send() | |
| 418 | + .with_context(|| format!("GET {}", url))?; | |
| 412 | 419 | if !resp.status().is_success() { |
| 413 | 420 | bail!("HTTP {} for {}", resp.status(), url); |
| 414 | 421 | } |
| @@ -419,34 +426,64 @@ fn fetch_text(url: &str) -> Result<String> { | ||
| 419 | 426 | fn github_stars(link: &str) -> Result<u64> { |
| 420 | 427 | // Expect forms like https://github.com/owner/repo or git@github.com:owner/repo.git |
| 421 | 428 | let lower = link.to_lowercase(); |
| 422 | - if !lower.contains("github.com") { bail!("not github"); } | |
| 423 | - | |
| 424 | - // Try to extract owner/repo | |
| 425 | - let (owner, repo) = if let Ok(parsed) = url::Url::parse(link) { | |
| 426 | - if parsed.domain().unwrap_or("") != "github.com" { bail!("not github"); } | |
| 427 | - let mut segs = parsed.path_segments().ok_or_else(|| anyhow::anyhow!("no path"))?; | |
| 428 | - let owner = segs.next().ok_or_else(|| anyhow::anyhow!("no owner"))?; | |
| 429 | - let mut repo = segs.next().ok_or_else(|| anyhow::anyhow!("no repo"))?; | |
| 430 | - if let Some(stripped) = repo.strip_suffix('.').or_else(|| repo.strip_suffix(".git")) { repo = stripped; } | |
| 431 | - (owner.to_string(), repo.to_string()) | |
| 429 | + if !lower.contains("github.com") { | |
| 430 | + bail!("not github"); | |
| 431 | + } | |
| 432 | + | |
| 433 | + // Try to extract owner/repo or infer repo if only owner provided | |
| 434 | + let mut try_owner_repo: Option<(String, String)> = None; | |
| 435 | + if let Ok(parsed) = url::Url::parse(link) { | |
| 436 | + if parsed.domain().unwrap_or("") != "github.com" { | |
| 437 | + bail!("not github"); | |
| 438 | + } | |
| 439 | + let mut segs = parsed | |
| 440 | + .path_segments() | |
| 441 | + .ok_or_else(|| anyhow::anyhow!("no path"))?; | |
| 442 | + let owner = segs | |
| 443 | + .next() | |
| 444 | + .ok_or_else(|| anyhow::anyhow!("no owner"))? | |
| 445 | + .to_string(); | |
| 446 | + if let Some(mut repo) = segs.next() { | |
| 447 | + if let Some(stripped) = repo.strip_suffix('.').or_else(|| repo.strip_suffix(".git")) { | |
| 448 | + repo = stripped; | |
| 449 | + } | |
| 450 | + try_owner_repo = Some((owner, repo.to_string())); | |
| 451 | + } else { | |
| 452 | + // Heuristic: try owner/owner as the repository | |
| 453 | + try_owner_repo = Some((owner.clone(), owner)); | |
| 454 | + } | |
| 432 | 455 | } else if let Some(rest) = lower.strip_prefix("git@github.com:") { |
| 433 | 456 | let parts: Vec<&str> = rest.split('/').collect(); |
| 434 | - if parts.len() < 2 { bail!("not owner/repo"); } | |
| 435 | - let mut repo = parts[1].to_string(); | |
| 436 | - if let Some(stripped) = repo.strip_suffix('.').or_else(|| repo.strip_suffix(".git")) { repo = stripped.to_string(); } | |
| 437 | - (parts[0].to_string(), repo) | |
| 438 | - } else { | |
| 439 | - bail!("unrecognized github url"); | |
| 440 | - }; | |
| 457 | + if parts.len() >= 2 { | |
| 458 | + let mut repo = parts[1].to_string(); | |
| 459 | + if let Some(stripped) = repo.strip_suffix('.').or_else(|| repo.strip_suffix(".git")) { | |
| 460 | + repo = stripped.to_string(); | |
| 461 | + } | |
| 462 | + try_owner_repo = Some((parts[0].to_string(), repo)); | |
| 463 | + } else if parts.len() == 1 { | |
| 464 | + let owner = parts[0].to_string(); | |
| 465 | + try_owner_repo = Some((owner.clone(), owner)); | |
| 466 | + } | |
| 467 | + } | |
| 468 | + | |
| 469 | + let (owner, repo) = try_owner_repo.ok_or_else(|| anyhow::anyhow!("unrecognized github url"))?; | |
| 441 | 470 | |
| 442 | 471 | let api = format!("https://api.github.com/repos/{}/{}", owner, repo); |
| 443 | 472 | let client = reqwest::blocking::Client::builder() |
| 444 | 473 | .user_agent("dotman/0.1") |
| 445 | 474 | .build() |
| 446 | 475 | .context("building http client")?; |
| 447 | - let resp = client.get(&api).send().with_context(|| format!("GET {}", api))?; | |
| 448 | - if !resp.status().is_success() { bail!("bad status") } | |
| 476 | + let resp = client | |
| 477 | + .get(&api) | |
| 478 | + .send() | |
| 479 | + .with_context(|| format!("GET {}", api))?; | |
| 480 | + if !resp.status().is_success() { | |
| 481 | + bail!("bad status") | |
| 482 | + } | |
| 449 | 483 | let v: serde_json::Value = resp.json().context("parsing github json")?; |
| 450 | - let stars = v.get("stargazers_count").and_then(|n| n.as_u64()).unwrap_or(0); | |
| 484 | + let stars = v | |
| 485 | + .get("stargazers_count") | |
| 486 | + .and_then(|n| n.as_u64()) | |
| 487 | + .unwrap_or(0); | |
| 451 | 488 | Ok(stars) |
| 452 | 489 | } |