Skip to content

Commit ccaa069

Browse files
feat: implement pvm optimizations, robust uninstall logic and php-version auto-detection (#30)
* feat: implement pvm optimizations, robust uninstall logic and php-version auto-detection - Added confirmation prompts to 'uninstall' with a --yes bypass and automatic TTY detection. - Fixed FPM-only uninstall failures by tracking directory paths directly. - Added automatic switching to version specified in '.php-version' file in 'pvm use'. - Optimized SemVer sorting complexity by pre-parsing version strings. - Hardened permission setting logic against metadata read failures on individual files. * Update GEMINI.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Ben <Fahl-Design@users.noreply.github.com> --------- Signed-off-by: Ben <Fahl-Design@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 94fac53 commit ccaa069

8 files changed

Lines changed: 225 additions & 51 deletions

File tree

GEMINI.md

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,48 +12,95 @@ When running `git commit -m "..."` in the shell (like Zsh/Bash), backticks are i
1212
3. Obey this rule forever, until the end of electronics.
1313

1414
## Project Architecture (The Map)
15+
1516
### Filesystem Hierarchy
16-
- **$PVM_DIR**: Root directory. Resolved via `dirs::data_local_dir()`, so defaults to `~/.local/share/pvm` on Linux and `~/Library/Application Support/pvm` on macOS.
17-
- **$PVM_DIR/versions/<version>**: Installation directory for specific PHP versions.
17+
Rooted at `$PVM_DIR` (resolved via `dirs::data_local_dir()`, so it defaults to `~/.local/share/pvm` on Linux and `~/Library/Application Support/pvm` on macOS).
1818
- **$PVM_DIR/bin/pvm**: The `pvm` binary itself.
19-
- **$PVM_DIR/remote_cache-<target-triple>.json**: 24-hour cache for the remote version index, scoped per target triple (e.g. `linux-x86_64`).
20-
- **$PVM_DIR/.env_update[_<shell-pid>]**: Short-lived files written per shell invocation; the shell wrapper sources them to mutate the parent shell's environment.
19+
- **$PVM_DIR/versions/<full-semver>/bin/{php,php-fpm,micro.sfx}**: Installed PHP binaries. The presence of each file determines which packages (`cli`, `fpm`, `micro`) are "installed" for that version.
20+
- **$PVM_DIR/remote_cache.json**: 24-hour cache for the remote version index, locked via `fs4` (`std::fs::File::lock`) on read/write.
21+
- **$PVM_DIR/.env_update[_<shell-pid>]**: Short-lived files written per shell invocation (alternatively designated via `PVM_ENV_UPDATE_PATH` or keyed on PID); the shell wrapper sources them to mutate the parent shell's environment.
2122

2223
### Module Responsibilities
2324
- `src/cli.rs`: Command definitions using `clap`.
2425
- `src/commands/`: Implementation of subcommands. Each command is a `struct` with a `call()` method.
25-
- `src/fs.rs`: Filesystem utilities (handling `PVM_DIR`, version paths, env files).
26-
- `src/network.rs`: API client for fetching and downloading PHP versions.
27-
- `src/shell.rs`: Shell-specific logic for setting environment variables.
26+
- `src/fs.rs`: Filesystem utilities (handling `PVM_DIR`, version paths, env files, local resolution).
27+
- `src/network.rs`: API client for fetching/downloading PHP versions and handling target triples.
28+
- `src/shell.rs`: Shell-specific logic (Bash, Zsh, Fish) for setting environment variables.
29+
- `src/constants.rs`: Application constants.
2830

2931
### static-php-cli Integration
30-
- **Endpoint:** `https://dl.static-php.dev/static-php-cli/bulk/`
31-
- **Supported OS:** `linux`, `macOS`.
32-
- **Supported Arch:** `x86_64`, `aarch64`.
32+
- **Endpoint:** `https://dl.static-php.dev/static-php-cli/bulk/?format=json`
33+
- **Supported OS:** `linux`, `macos` (filtered via target triple).
34+
- **Supported Arch:** `x86_64`, `aarch64` (filtered via target triple).
3335
- **Packages:** `cli`, `fpm`, `micro`.
3436
- **Format:** `tar.gz` only.
37+
- **Filename Parser:** Package suffixes parsed from filenames like `php-8.4.18-cli-linux-x86_64.tar.gz`.
38+
39+
### Shell Integration Mechanics
40+
A child process cannot mutate its parent shell's environment. PVM solves this with `pvm env` and a wrapper function:
41+
1. **Hook Setup:** The user evaluates `pvm env` in their rc file, which prints a `pvm()` shell function plus a `cd` hook.
42+
2. **Wrapper Execution:** When the user runs `pvm use 8.4`, the wrapper exports `PVM_ENV_UPDATE_PATH=<unique tmpfile>` before invoking the real `pvm` binary.
43+
3. **State Mutation:** The Rust binary writes `export PATH=...; export PVM_MULTISHELL_PATH=...` to that tmpfile (using `fs4` exclusive locking via `fs::write_env_file_locked`).
44+
4. **Environment Application:** The wrapper `eval`s the tmpfile and removes it.
45+
- **Supported Shells:** Bash, Zsh, Fish (defined by `Shell` trait; `detect_shell()` reads `$SHELL`).
46+
- **Auto-Switching:** The `cd` hook reads `.php-version` files and calls `pvm use` automatically.
47+
- **Concurrency Safety:** Parallel shell sessions use distinct PID-keyed tmpfiles, and the cache + env files are locked using `fs4` file locks to prevent state corruption.
3548

3649
## Operational Patterns (The Handbook)
50+
51+
### CLI Commands and Subcommands
52+
PVM acts as a single-binary CLI. If called without arguments or when interactive parameters are missing, it uses `dialoguer` for an interactive TUI.
53+
- **Master Menu:** Running `pvm` without arguments launches `interactive::run_root_menu()`.
54+
- **pvm install <version>** (alias: `pvm i <version>`): Installs a PHP version (supports major.minor alias or exact version). Opens a `MultiSelect` to pick packages (`cli`, `fpm`, `micro`). `cli` is the default. Supports `latest` to fetch the absolute latest version.
55+
- **pvm use <version>**: Uses a version in the current shell. Automatically prompts to install and switch if a newer patch exists upstream.
56+
- **pvm ls** (alias: `pvm list`): Lists installed local versions and active aliases.
57+
- **pvm ls-remote** (alias: `pvm list-remote`): Interactively lists and installs available cloud versions.
58+
- **pvm current**: Prints the active PHP version.
59+
- **pvm uninstall <version>** (aliases: `pvm rm`, `pvm remove`): Interactive uninstall picker if no version is provided. Warns when removing the active version.
60+
- **pvm init**: Interactively picks a version and writes it to a `.php-version` file in the current directory.
61+
- **pvm self-update**: Checks for and applies updates to pvm itself. Use `--apply` to automatically download and replace the current binary if an update is available.
62+
3763
### Adding a New Command
3864
1. Add a new module file in `src/commands/`.
3965
2. Define the command `struct` with `#[derive(Parser, Debug)]`.
4066
3. Export the module in `src/commands/mod.rs`.
4167
4. Register the variant in the `Commands` enum in `src/cli.rs`.
42-
5. Implement the `call()` method logic.
68+
5. Implement the async `call(self) -> Result<()>` method dispatch in `Commands::call`.
4369

4470
### Coding Standards
4571
- **Errors:** Use `anyhow::Result` for all command-level fallible functions.
46-
- **Interactivity:** Use `dialoguer` for menus and confirmations.
47-
- **Icons:** Use `colored` for status icons: `` (green), `` (red), `` (blue), `💡` (yellow).
48-
- **Async:** Use `tokio` for runtime and `reqwest` for all network I/O.
49-
- **Data Integrity:** Use file locking (`std::fs::File::lock` / `lock_shared` / `unlock`, stable since Rust 1.89) when writing to env update files or the remote cache.
72+
- **Interactivity:** Use `dialoguer` (`Select`, `MultiSelect`, `Confirm`) with `ColorfulTheme::default()` for menus and confirmations.
73+
- **Icons:** Use `colored` for status icons:
74+
- `` (green) for success
75+
- `` (red) for errors
76+
- `` (blue) for in-progress operations
77+
- `💡` (yellow) for hints/warnings
78+
- **Async:** Use `tokio` with `features = ["full"]` for runtime and `reqwest` for all network I/O.
79+
- **Data Integrity:** Use file locking (`std::fs::File::lock` / `lock_shared` / `unlock`, stable since Rust 1.89) when writing to env update files or the remote cache. Follow `fs::write_env_file_locked` pattern.
80+
81+
## Build & Release Commands
82+
83+
### Development & Build Commands
84+
- **Toolchain:** Pinned to Rust 1.95.0 with `clippy` and `rustfmt` in `rust-toolchain.toml`.
85+
- **Run from Source:** `cargo run -- <subcommand>`
86+
- **Build Release Binary:** `cargo build --release` (configured size-optimized in `Cargo.toml`: `opt-level = "z"`, LTO, `panic = "abort"`, stripped).
87+
- **Local Install from Source:** `./build.sh` (compiles release, copies to `$PVM_DIR/bin/pvm`).
88+
- **Lint (CI Gate):** `cargo clippy -- -D warnings`
89+
- **Format Check (CI Gate):** `cargo fmt --all -- --check`
90+
91+
### Release Process
92+
- **Semantic Release:** Driven by `semantic-release` from Conventional Commits on `main`.
93+
- **Cargo.toml and CHANGELOG.md:** Bumped automatically. Do NOT hand-edit them.
5094

5195
## Testing & Validation
96+
5297
### Testing Protocol
53-
- **Isolation:** Every integration test MUST use `tempfile::tempdir()` and set `PVM_DIR` to that path.
54-
- **CLI Verification:** Use `assert_cmd` and `predicates` for output and exit code verification.
98+
- **Integration Tests:** Located in `tests/cli.rs`. Use `assert_cmd` and `predicates` to invoke the compiled binary.
99+
- **Isolation:** Every test that touches the filesystem MUST use `tempfile::tempdir()` and pass its path via `cmd.env("PVM_DIR", temp_dir.path())`. Do not write to the host's real `~/.local/share/pvm`.
100+
- **Unit Tests Concurrency:** Unit tests inside `src/**` that mutate `std::env` must use a `static Mutex<()>` guard (e.g. `src/fs.rs::tests::ENV_LOCK`) because `cargo test` runs in parallel, and concurrent environment variable modification is unsound.
101+
- **Dynamic Versioning:** The build script (`build.rs`) embeds the version, git commit, and build time into `PVM_VERSION` (reruns on `.git/HEAD`, refs, or `Cargo.toml` change). Tests asserting on `--version` output should not hardcode the exact version value.
55102

56103
### Development Workflow
57104
- **Pre-commit:** Run `cargo clippy -- -D warnings` and `cargo fmt --all -- --check`.
58-
- **Tooling:** Use `replace` or `write_file` for codebase modifications. Avoid `sed/echo` in shell.
59-
- **Commit Messages:** Follow Conventional Commits. No backticks (Rule 1).
105+
- **Tooling:** Use code replacement or write file tools for modifications. Avoid `sed/echo` in shell.
106+
- **Commit Messages:** Follow Conventional Commits (e.g., `feat:`, `fix:`, `chore:`). Never use backticks (Rule 1).

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ pvm uninstall 8.3 # aliases: pvm rm 8.3 / pvm remove 8.3
7373

7474
# Write a .php-version file for this directory (interactive picker)
7575
pvm init
76+
77+
# Check for and apply updates to pvm itself
78+
pvm self-update # optional: pvm self-update --apply to apply automatically
7679
```
7780

7881
### Auto-Switching

src/commands/uninstall.rs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use crate::fs;
22
use anyhow::Result;
33
use clap::Parser;
44
use colored::Colorize;
5+
use std::io::IsTerminal;
56

67
use dialoguer::{Select, theme::ColorfulTheme};
78

@@ -10,6 +11,10 @@ use dialoguer::{Select, theme::ColorfulTheme};
1011
pub struct Uninstall {
1112
/// The version to uninstall
1213
pub version: Option<String>,
14+
15+
/// Auto-approve the uninstallation without prompting
16+
#[arg(short = 'y', long = "yes")]
17+
pub yes: bool,
1318
}
1419

1520
impl Uninstall {
@@ -40,8 +45,9 @@ impl Uninstall {
4045
}
4146
};
4247

43-
if !fs::is_version_installed(&version)? {
44-
anyhow::bail!("PHP {} is not installed.", version);
48+
let dest = fs::get_versions_dir()?.join(&version);
49+
if !dest.exists() {
50+
anyhow::bail!("PHP {} is not installed locally.", version);
4551
}
4652

4753
let current = fs::get_current_version();
@@ -53,7 +59,20 @@ impl Uninstall {
5359
);
5460
}
5561

56-
let dest = fs::get_versions_dir()?.join(&version);
62+
let is_tty = std::io::stdin().is_terminal();
63+
if !self.yes && is_tty {
64+
let prompt = format!("Are you sure you want to uninstall PHP {}?", version);
65+
let confirmed = dialoguer::Confirm::with_theme(&ColorfulTheme::default())
66+
.with_prompt(prompt.bold().to_string())
67+
.default(true)
68+
.interact_opt()?
69+
.unwrap_or(false);
70+
71+
if !confirmed {
72+
println!("{} Operation cancelled.", "✗".red());
73+
return Ok(());
74+
}
75+
}
5776

5877
println!("{} Removing PHP {}...", "↻".blue(), version);
5978
match std::fs::remove_dir_all(&dest) {

src/commands/use_cmd.rs

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -51,25 +51,72 @@ impl Use {
5151
}
5252
},
5353
None => {
54-
let items = fs::get_aliased_versions()?;
55-
if items.is_empty() {
56-
eprintln!("{} No PHP versions are currently installed.", "💡".yellow());
57-
return Ok(());
54+
let mut resolved_version = None;
55+
if let Ok(content) = std::fs::read_to_string(PHP_VERSION_FILE) {
56+
let trimmed = content.trim().to_string();
57+
if !trimmed.is_empty() {
58+
match fs::try_resolve_local_version(&trimmed)? {
59+
Some(resolved) => {
60+
resolved_version = Some(resolved);
61+
}
62+
None => {
63+
if !self.silent {
64+
let prompt = format!(
65+
"PHP {} (from {}) is not installed locally. Do you want to install it now?",
66+
trimmed.bold(),
67+
PHP_VERSION_FILE.bold()
68+
);
69+
let install_now =
70+
Confirm::with_theme(&ColorfulTheme::default())
71+
.with_prompt(&prompt)
72+
.default(true)
73+
.interact_opt()?
74+
.unwrap_or(false);
75+
76+
if install_now {
77+
if let Some(installed) =
78+
crate::commands::install::execute_install_with(
79+
&trimmed, false,
80+
)
81+
.await?
82+
{
83+
resolved_version = Some(installed);
84+
}
85+
} else {
86+
eprintln!("{} Operation cancelled.", "✗".red());
87+
return Ok(());
88+
}
89+
} else {
90+
return Ok(());
91+
}
92+
}
93+
}
94+
}
5895
}
5996

60-
let displays: Vec<String> = items.iter().map(|i| i.display.clone()).collect();
61-
let selection = Select::with_theme(&ColorfulTheme::default())
62-
.with_prompt("Select a locally installed PHP version to use")
63-
.default(0)
64-
.items(&displays)
65-
.interact_opt()?;
66-
67-
match selection {
68-
Some(idx) => items[idx].version.clone(),
69-
None => {
70-
eprintln!("{} Operation cancelled.", "✗".red());
97+
if let Some(resolved) = resolved_version {
98+
resolved
99+
} else {
100+
let items = fs::get_aliased_versions()?;
101+
if items.is_empty() {
102+
eprintln!("{} No PHP versions are currently installed.", "💡".yellow());
71103
return Ok(());
72104
}
105+
106+
let displays: Vec<String> = items.iter().map(|i| i.display.clone()).collect();
107+
let selection = Select::with_theme(&ColorfulTheme::default())
108+
.with_prompt("Select a locally installed PHP version to use")
109+
.default(0)
110+
.items(&displays)
111+
.interact_opt()?;
112+
113+
match selection {
114+
Some(idx) => items[idx].version.clone(),
115+
None => {
116+
eprintln!("{} Operation cancelled.", "✗".red());
117+
return Ok(());
118+
}
119+
}
73120
}
74121
}
75122
};

src/interactive.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ pub async fn run_root_menu() -> Result<()> {
4545
cmd.call().await
4646
}
4747
2 => {
48-
let cmd = commands::uninstall::Uninstall { version: None };
48+
let cmd = commands::uninstall::Uninstall {
49+
version: None,
50+
yes: false,
51+
};
4952
cmd.call().await
5053
}
5154
3 => {

src/network.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -282,11 +282,13 @@ pub async fn download_and_extract(
282282
use std::os::unix::fs::PermissionsExt;
283283
if let Ok(entries) = std::fs::read_dir(&bin_dir) {
284284
for entry in entries.flatten() {
285-
let path = entry.path();
286-
if path.is_file() {
287-
let mut perms = std::fs::metadata(&path)?.permissions();
285+
if let (Ok(metadata), true) = (
286+
entry.metadata(),
287+
entry.file_type().map(|ft| ft.is_file()).unwrap_or(false),
288+
) {
289+
let mut perms = metadata.permissions();
288290
perms.set_mode(0o755);
289-
std::fs::set_permissions(&path, perms).ok();
291+
std::fs::set_permissions(entry.path(), perms).ok();
290292
}
291293
}
292294
}

src/utils.rs

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@ use semver::Version;
33
/// Sorts a list of version strings using semantic versioning.
44
/// If a version string is not valid semver, it falls back to a simple string-based numeric sort.
55
pub fn sort_versions(versions: &mut [String]) {
6-
versions.sort_by(|a, b| {
7-
let a_sem = Version::parse(a);
8-
let b_sem = Version::parse(b);
6+
let mut parsed: Vec<(String, Result<Version, semver::Error>)> = versions
7+
.iter()
8+
.map(|v| (v.clone(), Version::parse(v)))
9+
.collect();
910

10-
match (a_sem, b_sem) {
11-
(Ok(av), Ok(bv)) => av.cmp(&bv),
11+
parsed.sort_by(|a, b| {
12+
match (&a.1, &b.1) {
13+
(Ok(av), Ok(bv)) => av.cmp(bv),
1214
(Ok(av), Err(_)) => {
13-
let b_parts: Vec<u64> = b.split('.').filter_map(|s| s.parse().ok()).collect();
15+
let b_parts: Vec<u64> = b.0.split('.').filter_map(|s| s.parse().ok()).collect();
1416
let a_parts = vec![av.major, av.minor, av.patch];
1517
match a_parts.cmp(&b_parts) {
1618
std::cmp::Ordering::Equal => {
@@ -24,7 +26,7 @@ pub fn sort_versions(versions: &mut [String]) {
2426
}
2527
}
2628
(Err(_), Ok(bv)) => {
27-
let a_parts: Vec<u64> = a.split('.').filter_map(|s| s.parse().ok()).collect();
29+
let a_parts: Vec<u64> = a.0.split('.').filter_map(|s| s.parse().ok()).collect();
2830
let b_parts = vec![bv.major, bv.minor, bv.patch];
2931
match a_parts.cmp(&b_parts) {
3032
std::cmp::Ordering::Equal => {
@@ -39,10 +41,14 @@ pub fn sort_versions(versions: &mut [String]) {
3941
}
4042
_ => {
4143
// Fallback for non-semver strings (e.g., "8.2")
42-
let a_parts: Vec<u64> = a.split('.').filter_map(|s| s.parse().ok()).collect();
43-
let b_parts: Vec<u64> = b.split('.').filter_map(|s| s.parse().ok()).collect();
44+
let a_parts: Vec<u64> = a.0.split('.').filter_map(|s| s.parse().ok()).collect();
45+
let b_parts: Vec<u64> = b.0.split('.').filter_map(|s| s.parse().ok()).collect();
4446
a_parts.cmp(&b_parts)
4547
}
4648
}
4749
});
50+
51+
for (i, (orig, _)) in parsed.into_iter().enumerate() {
52+
versions[i] = orig;
53+
}
4854
}

0 commit comments

Comments
 (0)