diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..6a266e8 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +dev-install = "run -p xtask --release -q -- install" diff --git a/CLAUDE.md b/CLAUDE.md index 1cafc6a..3fab8f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,8 @@ src/ └── mod.rs # TOML parsing, XDG paths, default keybindings ``` +The repo is a Cargo workspace. The only non-root member is `xtask/` — dev tooling (`publish = false`, `dist = false`, never shipped in releases) that provides the `cargo dev-install` / `cargo run -p xtask -- uninstall` commands. + ## Core Concepts ### File List (zero-config) @@ -149,6 +151,7 @@ cargo test # Run tests cargo clippy # Lint cargo fmt # Format RUST_LOG=debug cargo run # Run with debug logging +cargo dev-install # symlink the debug build into ~/.local/bin (xtask crate) ``` ## Current Status diff --git a/Cargo.lock b/Cargo.lock index 6332f83..9fdf065 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3498,6 +3498,14 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xtask" +version = "0.0.0" +dependencies = [ + "anyhow", + "tempfile", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index d256b8d..e7044a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,10 @@ keywords = ["git", "tui", "terminal", "dashboard", "developer-tools"] categories = ["command-line-utilities", "development-tools"] default-run = "perch" +[workspace] +members = ["xtask"] +resolver = "2" + [[bin]] name = "perch" path = "src/main.rs" diff --git a/README.md b/README.md index 893313f..65e254b 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,18 @@ cargo clippy # lint cargo fmt # format ``` +### Dev install (run your local build as `perch`) + +```bash +cargo dev-install # build debug + symlink into ~/.local/bin +cargo run -p xtask -- uninstall # remove the symlink +``` + +This symlinks `target/debug/perch` into `~/.local/bin/perch`, so every later +`cargo build` is instantly live. It does **not** touch a Homebrew-installed +`perch` (that lives in the brew prefix); which one runs depends on PATH order — +the command warns if `~/.local/bin` is missing from PATH or shadowed. + ### Nix commands ```bash diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..a68151f --- /dev/null +++ b/build.rs @@ -0,0 +1,66 @@ +//! Build script that captures git describe information for the version string. +//! +//! Emits the `PERCH_GIT_DESCRIBE` env var consumed by `src/main.rs`: +//! - `<7-char-sha>` on a clean tree +//! - `<7-char-sha>.dirty` when the index or working tree has uncommitted changes +//! - `unknown` when git is unavailable (e.g. building from a source tarball) +//! +//! # Staleness caveat +//! Cargo re-runs this script only when the listed `rerun-if-changed` paths +//! change. We watch `.git/HEAD` (catches commits and branch switches) and +//! `.git/index` (catches staged changes, which also cover most `git add` +//! operations). A pure working-tree edit that has *not* been staged will not +//! trigger a rebuild, so the `.dirty` flag may lag until the next `git add` or +//! any other event that touches `.git/index`. + +use std::process::Command; + +fn main() { + // Tell Cargo to re-run this script when HEAD changes (commits, checkouts) + // or when the index changes (staging). Cargo tolerates missing paths by + // treating them as always-changed, so these are safe to emit unconditionally + // (e.g. when building from a tarball without a .git directory). + println!("cargo:rerun-if-changed=.git/HEAD"); + println!("cargo:rerun-if-changed=.git/index"); + + let describe = git_describe().unwrap_or_else(|| "unknown".to_owned()); + println!("cargo:rustc-env=PERCH_GIT_DESCRIBE={describe}"); +} + +/// Returns `Some("")` or `Some(".dirty")` when git is available, +/// `None` when any git invocation fails. +fn git_describe() -> Option { + let sha = short_sha()?; + let dirty = is_dirty()?; + if dirty { + Some(format!("{sha}.dirty")) + } else { + Some(sha) + } +} + +/// Runs `git rev-parse --short=7 HEAD` and returns the trimmed output. +fn short_sha() -> Option { + let output = Command::new("git") + .args(["rev-parse", "--short=7", "HEAD"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let sha = String::from_utf8(output.stdout).ok()?; + Some(sha.trim().to_owned()) +} + +/// Runs `git status --porcelain` and returns `true` when the output is +/// non-empty (i.e. there are staged or unstaged changes). +fn is_dirty() -> Option { + let output = Command::new("git") + .args(["status", "--porcelain"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + Some(!output.stdout.is_empty()) +} diff --git a/src/main.rs b/src/main.rs index f822d45..66c9ff1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,10 +15,23 @@ use anyhow::{Context, Result}; use clap::Parser; use tracing_subscriber::EnvFilter; +/// Version reported by `--version`. Debug builds append the git describe +/// string (`-dev.[.dirty]`); release builds report the clean crate +/// version. `PERCH_GIT_DESCRIBE` is set by build.rs. +const VERSION: &str = if cfg!(debug_assertions) { + concat!( + env!("CARGO_PKG_VERSION"), + "-dev.", + env!("PERCH_GIT_DESCRIBE") + ) +} else { + env!("CARGO_PKG_VERSION") +}; + #[derive(Parser, Debug)] #[command( name = "perch", - version, + version = VERSION, about = "Real-time terminal dashboard for git changes" )] struct Cli { @@ -102,3 +115,19 @@ fn main() -> Result<()> { ); app.run() } + +#[cfg(test)] +mod tests { + use super::VERSION; + + #[test] + fn version_has_dev_suffix_in_debug_builds() { + // `cargo test` compiles with debug_assertions enabled, so VERSION must + // carry the dev describe suffix and still start with the crate version. + assert!( + VERSION.starts_with(env!("CARGO_PKG_VERSION")), + "got {VERSION}" + ); + assert!(VERSION.contains("-dev."), "got {VERSION}"); + } +} diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..a99f5ba --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "xtask" +version = "0.0.0" +edition = "2021" +publish = false + +[package.metadata.dist] +dist = false + +[dependencies] +anyhow = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/xtask/src/install.rs b/xtask/src/install.rs new file mode 100644 index 0000000..8ae6f9c --- /dev/null +++ b/xtask/src/install.rs @@ -0,0 +1,128 @@ +//! Filesystem helpers for the dev-install xtask. + +use std::fs; +use std::path::Path; + +use anyhow::{bail, Context, Result}; + +/// Symlink each named binary from `target_dir` into `bin_dir`. +/// +/// Replaces an existing symlink at the destination, but refuses to clobber a +/// real (non-symlink) file. Creates `bin_dir` if it does not exist. +pub fn link_binaries(bin_dir: &Path, target_dir: &Path, names: &[&str]) -> Result<()> { + fs::create_dir_all(bin_dir) + .with_context(|| format!("creating bin dir {}", bin_dir.display()))?; + + for name in names { + let src = target_dir.join(name); + let dest = bin_dir.join(name); + + match dest.symlink_metadata() { + Ok(meta) if meta.file_type().is_symlink() => { + fs::remove_file(&dest) + .with_context(|| format!("removing existing symlink {}", dest.display()))?; + } + Ok(_) => bail!( + "refusing to overwrite real file {} (not a symlink)", + dest.display() + ), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e).with_context(|| format!("inspecting {}", dest.display())), + } + + std::os::unix::fs::symlink(&src, &dest) + .with_context(|| format!("symlinking {} -> {}", dest.display(), src.display()))?; + } + + Ok(()) +} + +/// Remove `name` from `bin_dir` only if it is a symlink. Returns whether +/// anything was removed. Refuses to delete a real (non-symlink) file. +pub fn unlink_binary(bin_dir: &Path, name: &str) -> Result { + let dest = bin_dir.join(name); + match dest.symlink_metadata() { + Ok(meta) if meta.file_type().is_symlink() => { + fs::remove_file(&dest) + .with_context(|| format!("removing symlink {}", dest.display()))?; + Ok(true) + } + Ok(_) => bail!("{} is not a symlink; leaving it untouched", dest.display()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(e).with_context(|| format!("inspecting {}", dest.display())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn links_fresh_binary() { + let bin = tempdir().unwrap(); + let target = tempdir().unwrap(); + fs::write(target.path().join("perch"), b"binary").unwrap(); + + link_binaries(bin.path(), target.path(), &["perch"]).unwrap(); + + let link = bin.path().join("perch"); + assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); + assert_eq!(fs::read_link(&link).unwrap(), target.path().join("perch")); + } + + #[test] + fn relink_replaces_existing_symlink() { + let bin = tempdir().unwrap(); + let target = tempdir().unwrap(); + fs::write(target.path().join("perch"), b"binary").unwrap(); + + link_binaries(bin.path(), target.path(), &["perch"]).unwrap(); + // second call must not error + link_binaries(bin.path(), target.path(), &["perch"]).unwrap(); + + let link = bin.path().join("perch"); + assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); + } + + #[test] + fn refuses_to_clobber_real_file() { + let bin = tempdir().unwrap(); + let target = tempdir().unwrap(); + let real = bin.path().join("perch"); + fs::write(&real, b"do not delete me").unwrap(); + + let err = link_binaries(bin.path(), target.path(), &["perch"]).unwrap_err(); + assert!(err.to_string().contains("refusing to overwrite")); + assert_eq!(fs::read(&real).unwrap(), b"do not delete me"); + } + + #[test] + fn unlink_removes_symlink() { + let bin = tempdir().unwrap(); + let target = tempdir().unwrap(); + fs::write(target.path().join("perch"), b"binary").unwrap(); + link_binaries(bin.path(), target.path(), &["perch"]).unwrap(); + + assert!(unlink_binary(bin.path(), "perch").unwrap()); + assert!(bin.path().join("perch").symlink_metadata().is_err()); + } + + #[test] + fn unlink_missing_is_noop() { + let bin = tempdir().unwrap(); + assert!(!unlink_binary(bin.path(), "perch").unwrap()); + } + + #[test] + fn unlink_refuses_real_file() { + let bin = tempdir().unwrap(); + let real = bin.path().join("perch"); + fs::write(&real, b"keep me").unwrap(); + + let err = unlink_binary(bin.path(), "perch").unwrap_err(); + assert!(err.to_string().contains("not a symlink")); + assert!(real.exists()); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..3597977 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,128 @@ +mod install; + +use std::env; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{bail, Context, Result}; + +use install::{link_binaries, unlink_binary}; + +/// Binaries the dev tool manages. Intentionally only `perch` (not `git-perch`). +const BINARIES: &[&str] = &["perch"]; + +fn main() -> Result<()> { + match env::args().nth(1).as_deref() { + Some("install") => install_cmd(), + Some("uninstall") => uninstall_cmd(), + other => bail!( + "usage: cargo run -p xtask -- (got {:?})", + other + ), + } +} + +/// xtask's manifest dir is `/xtask`; its parent is the workspace root. +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask has a parent dir") + .to_path_buf() +} + +fn target_debug_dir() -> PathBuf { + let base = match env::var("CARGO_TARGET_DIR") { + Ok(dir) => { + let p = PathBuf::from(dir); + if p.is_absolute() { + p + } else { + workspace_root().join(p) + } + } + Err(_) => workspace_root().join("target"), + }; + base.join("debug") +} + +fn bin_dir() -> Result { + let home = env::var("HOME").context("HOME is not set")?; + Ok(PathBuf::from(home).join(".local").join("bin")) +} + +fn install_cmd() -> Result<()> { + let cargo = env::var("CARGO").unwrap_or_else(|_| "cargo".to_string()); + let status = Command::new(&cargo) + .args(["build", "--bin", "perch"]) + .current_dir(workspace_root()) + .status() + .context("running cargo build")?; + if !status.success() { + bail!("cargo build failed"); + } + + let bin = bin_dir()?; + let target = target_debug_dir(); + + for name in BINARIES { + let built = target.join(name); + if !built.exists() { + bail!( + "expected built binary {} not found after build", + built.display() + ); + } + } + + link_binaries(&bin, &target, BINARIES)?; + + for name in BINARIES { + println!( + "linked {} -> {}", + bin.join(name).display(), + target.join(name).display() + ); + } + + warn_path(&bin); + Ok(()) +} + +fn uninstall_cmd() -> Result<()> { + let bin = bin_dir()?; + for name in BINARIES { + if unlink_binary(&bin, name)? { + println!("removed {}", bin.join(name).display()); + } else { + println!("{} not installed; nothing to do", bin.join(name).display()); + } + } + Ok(()) +} + +/// Warn if `bin` is not on PATH, or if an earlier PATH entry shadows our binary. +fn warn_path(bin: &Path) { + let path = env::var("PATH").unwrap_or_default(); + let entries: Vec = env::split_paths(&path).collect(); + + if !entries.iter().any(|p| p == bin) { + eprintln!( + "warning: {} is not on your PATH; add it to run `perch` from anywhere", + bin.display() + ); + return; + } + + for entry in &entries { + if entry == bin { + break; + } + if entry.join("perch").exists() { + eprintln!( + "warning: {} is earlier on PATH and will shadow the dev build", + entry.join("perch").display() + ); + break; + } + } +}