The dotfiles manager.
refactor: use function to get the dothub path
1 file changed, +63 -16
+63-16src/main.rs
| @@ -10,16 +10,21 @@ use std::time::Duration; | ||
| 10 | 10 | use std::fs; |
| 11 | 11 | use std::io; |
| 12 | 12 | use std::io::Write; |
| 13 | -use std::path::Path; | |
| 13 | +use std::path::{Path, PathBuf}; | |
| 14 | 14 | use std::process::Command; |
| 15 | 15 | |
| 16 | -const DOTHUB_DIR: &str = "/usr/local/share/dothub"; | |
| 16 | +const DEFAULT_DOTHUB_PATH: &str = ".local/share/dothub"; | |
| 17 | 17 | const DEFAULT_HUB_URL: &str = |
| 18 | - "https://raw.githubusercontent.com/huncholane/dothub/refs/heads/main/hub.yml"; | |
| 18 | + "https://raw.githubusercontent.com/huncholane/dothub/main/hub.yml"; | |
| 19 | 19 | const GH_TOKEN_HELP_URL: &str = "https://github.com/settings/personal-access-tokens"; |
| 20 | 20 | |
| 21 | 21 | #[derive(Parser)] |
| 22 | -#[command(name = "dothub", about = "Manage dotfile repos and links", version)] | |
| 22 | +#[command( | |
| 23 | + name = "dothub", | |
| 24 | + about = "Manage dotfile repos and links", | |
| 25 | + version, | |
| 26 | + after_help = "Environment variables:\n DOTHUB_DIR Override the store directory (default: XDG data dir, e.g. ~/.local/share/dothub)\n GITHUB_TOKEN GitHub token to speed up star fetching via GraphQL (optional)" | |
| 27 | +)] | |
| 23 | 28 | struct Cli { |
| 24 | 29 | /// Optional filter: types to include (e.g. nvim, tmux). Comma-separated or space-separated. |
| 25 | 30 | #[arg(value_name = "TYPE", num_args = 0.., value_delimiter = ',')] |
| @@ -44,6 +49,8 @@ enum Commands { | ||
| 44 | 49 | Active, |
| 45 | 50 | /// List repositories installed in the dothub store |
| 46 | 51 | List, |
| 52 | + /// Remove a repository from the dothub store | |
| 53 | + Remove(RemoveArgs), | |
| 47 | 54 | /// Generate shell completions to stdout (bash|zsh|fish|powershell|elvish) |
| 48 | 55 | Completions { shell: Shell }, |
| 49 | 56 | } |
| @@ -64,6 +71,12 @@ struct LinkArgs { | ||
| 64 | 71 | target: String, |
| 65 | 72 | } |
| 66 | 73 | |
| 74 | +#[derive(Args)] | |
| 75 | +struct RemoveArgs { | |
| 76 | + /// Repository name stored under dothub to remove | |
| 77 | + name: String, | |
| 78 | +} | |
| 79 | + | |
| 67 | 80 | #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] |
| 68 | 81 | enum Shell { |
| 69 | 82 | Bash, |
| @@ -82,16 +95,31 @@ fn main() -> Result<()> { | ||
| 82 | 95 | Some(Commands::Update) => cmd_update(), |
| 83 | 96 | Some(Commands::Active) => cmd_active(), |
| 84 | 97 | Some(Commands::List) => cmd_list(), |
| 98 | + Some(Commands::Remove(args)) => cmd_remove(&args.name), | |
| 85 | 99 | Some(Commands::Completions { shell }) => cmd_completions(shell), |
| 86 | 100 | None => cmd_hub(cli.types, cli.url), |
| 87 | 101 | } |
| 88 | 102 | } |
| 89 | 103 | |
| 104 | +fn dothub_dir() -> PathBuf { | |
| 105 | + if let Ok(p) = env::var("DOTHUB_DIR") { | |
| 106 | + return PathBuf::from(p); | |
| 107 | + } | |
| 108 | + if let Some(mut data) = dirs::data_dir() { | |
| 109 | + data.push("dothub"); | |
| 110 | + return data; | |
| 111 | + } | |
| 112 | + if let Some(mut home) = dirs::home_dir() { | |
| 113 | + home.push(DEFAULT_DOTHUB_PATH); | |
| 114 | + return home; | |
| 115 | + } | |
| 116 | + PathBuf::from(".dothub") | |
| 117 | +} | |
| 118 | + | |
| 90 | 119 | fn ensure_store_dir() -> Result<()> { |
| 91 | - let path = Path::new(DOTHUB_DIR); | |
| 120 | + let path = dothub_dir(); | |
| 92 | 121 | if !path.exists() { |
| 93 | - fs::create_dir_all(path) | |
| 94 | - .with_context(|| format!("Failed creating {} (need sudo?)", DOTHUB_DIR))?; | |
| 122 | + fs::create_dir_all(&path).with_context(|| format!("Failed creating {}", path.display()))?; | |
| 95 | 123 | } |
| 96 | 124 | Ok(()) |
| 97 | 125 | } |
| @@ -110,7 +138,7 @@ fn cmd_install(repo: &str) -> Result<()> { | ||
| 110 | 138 | bail!("Could not infer repository name from URL: {}", repo); |
| 111 | 139 | } |
| 112 | 140 | |
| 113 | - let dest = Path::new(DOTHUB_DIR).join(&name); | |
| 141 | + let dest = dothub_dir().join(&name); | |
| 114 | 142 | if dest.exists() { |
| 115 | 143 | println!("Repo already exists: {}", dest.display()); |
| 116 | 144 | return Ok(()); |
| @@ -136,7 +164,7 @@ fn cmd_install(repo: &str) -> Result<()> { | ||
| 136 | 164 | } |
| 137 | 165 | |
| 138 | 166 | fn cmd_link(name: &str, target_name: &str) -> Result<()> { |
| 139 | - let source = Path::new(DOTHUB_DIR).join(name); | |
| 167 | + let source = dothub_dir().join(name); | |
| 140 | 168 | if !source.exists() { |
| 141 | 169 | bail!("Source repo not found: {}", source.display()); |
| 142 | 170 | } |
| @@ -198,11 +226,11 @@ fn cmd_update() -> Result<()> { | ||
| 198 | 226 | bail!("git is not installed or not found in PATH"); |
| 199 | 227 | } |
| 200 | 228 | |
| 201 | - let root = Path::new(DOTHUB_DIR); | |
| 229 | + let root = dothub_dir(); | |
| 202 | 230 | let mut updated = 0usize; |
| 203 | 231 | let mut skipped = 0usize; |
| 204 | 232 | |
| 205 | - for entry in fs::read_dir(root).with_context(|| format!("Reading {}", DOTHUB_DIR))? { | |
| 233 | + for entry in fs::read_dir(&root).with_context(|| format!("Reading {}", root.display()))? { | |
| 206 | 234 | let entry = entry?; |
| 207 | 235 | let path = entry.path(); |
| 208 | 236 | if !path.is_dir() { |
| @@ -306,7 +334,8 @@ fn cmd_active() -> Result<()> { | ||
| 306 | 334 | |
| 307 | 335 | let resolved = abs_target.canonicalize().unwrap_or(abs_target.clone()); |
| 308 | 336 | |
| 309 | - if resolved.starts_with(DOTHUB_DIR) { | |
| 337 | + let store = dothub_dir(); | |
| 338 | + if resolved.starts_with(&store) { | |
| 310 | 339 | let name = path |
| 311 | 340 | .file_name() |
| 312 | 341 | .and_then(|s| s.to_str()) |
| @@ -328,9 +357,9 @@ fn cmd_active() -> Result<()> { | ||
| 328 | 357 | |
| 329 | 358 | fn cmd_list() -> Result<()> { |
| 330 | 359 | ensure_store_dir()?; |
| 331 | - let root = Path::new(DOTHUB_DIR); | |
| 360 | + let root = dothub_dir(); | |
| 332 | 361 | let mut repos: Vec<String> = Vec::new(); |
| 333 | - for entry in fs::read_dir(root).with_context(|| format!("Reading {}", DOTHUB_DIR))? { | |
| 362 | + for entry in fs::read_dir(&root).with_context(|| format!("Reading {}", root.display()))? { | |
| 334 | 363 | let entry = entry?; |
| 335 | 364 | let path = entry.path(); |
| 336 | 365 | if !path.is_dir() { |
| @@ -344,7 +373,7 @@ fn cmd_list() -> Result<()> { | ||
| 344 | 373 | } |
| 345 | 374 | repos.sort(); |
| 346 | 375 | if repos.is_empty() { |
| 347 | - println!("No repositories installed in {}.", DOTHUB_DIR); | |
| 376 | + println!("No repositories installed in {}.", root.display()); | |
| 348 | 377 | } else { |
| 349 | 378 | for r in repos { |
| 350 | 379 | println!("{}", r); |
| @@ -353,6 +382,24 @@ fn cmd_list() -> Result<()> { | ||
| 353 | 382 | Ok(()) |
| 354 | 383 | } |
| 355 | 384 | |
| 385 | +fn cmd_remove(name: &str) -> Result<()> { | |
| 386 | + ensure_store_dir()?; | |
| 387 | + let path = dothub_dir().join(name); | |
| 388 | + if !path.exists() { | |
| 389 | + bail!("Repository not found: {}", path.display()); | |
| 390 | + } | |
| 391 | + let md = fs::symlink_metadata(&path).with_context(|| format!("stat {}", path.display()))?; | |
| 392 | + if md.is_dir() { | |
| 393 | + fs::remove_dir_all(&path) | |
| 394 | + .with_context(|| format!("Removing directory {}", path.display()))?; | |
| 395 | + } else { | |
| 396 | + fs::remove_file(&path) | |
| 397 | + .with_context(|| format!("Removing file {}", path.display()))?; | |
| 398 | + } | |
| 399 | + println!("Removed {}", name); | |
| 400 | + Ok(()) | |
| 401 | +} | |
| 402 | + | |
| 356 | 403 | #[derive(Debug, serde::Deserialize)] |
| 357 | 404 | #[serde(untagged)] |
| 358 | 405 | enum FlexEntry { |
| @@ -436,7 +483,7 @@ fn cmd_hub(types: Vec<String>, url: Option<String>) -> Result<()> { | ||
| 436 | 483 | for (idx, (_ty, link, stars)) in detailed.into_iter().enumerate() { |
| 437 | 484 | let rank = (idx + 1).to_string(); |
| 438 | 485 | let name = derive_repo_name(&link); |
| 439 | - let installed = Path::new(DOTHUB_DIR).join(&name).exists(); | |
| 486 | + let installed = dothub_dir().join(&name).exists(); | |
| 440 | 487 | let installed_str = if installed { "y" } else { "n" }; |
| 441 | 488 | table.add_row(vec![rank, stars.to_string(), installed_str.to_string(), link]); |
| 442 | 489 | } |