Skip to content

Commit 0b180ad

Browse files
committed
refactor: migrate directory layout to XDG Base Directory Specification
Replace the single ~/.zvm/ directory with XDG-compliant directory separation: - Config: $XDG_CONFIG_HOME/zvm/ (default ~/.config/zvm/) for settings - Data: $XDG_DATA_HOME/zvm/ (default ~/.local/share/zvm/) for installed versions, bin symlink, .active marker, and upgrade staging - Cache: $XDG_CACHE_HOME/zvm/ (default ~/.cache/zvm/) for version maps, downloaded archives, and temporary files Core changes: - platform.zig: replace getHomeDir() with getConfigDir(), getDataDir(), getCacheDir() that respect XDG environment variables with proper fallbacks; ZVM_PATH preserved as data directory override for backward compatibility - zvm.zig: replace single base_dir field with config_dir, data_dir, and cache_dir; update all path builder methods accordingly - install.zig: download archives to cache_dir, extract versions to data_dir, temp files use cache_dir - clean.zig: scan cache_dir for artifact cleanup - upgrade.zig: use data_dir/self for upgrade staging - completion.zig: update shell scripts to resolve data directory via XDG_DATA_HOME with proper fallback chain - install.sh: update PATH setup to use XDG data directory - README.md: document new layout, environment variables, and migration steps from legacy ~/.zvm/
1 parent 23e5573 commit 0b180ad

14 files changed

Lines changed: 194 additions & 93 deletions

README.md

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ zig build -Doptimize=ReleaseSafe
6363
Add zvm's bin directory to your PATH:
6464

6565
```bash
66-
export PATH="$HOME/.zvm/bin:$PATH"
66+
export PATH="${XDG_DATA_HOME:-$HOME/.local/share}/zvm/bin:$PATH"
6767
```
6868

6969
## Quick Start
@@ -217,28 +217,36 @@ eval "$(zvm completion bash)"
217217

218218
## How It Works
219219

220+
zvm follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir/latest/):
221+
220222
```
221-
~/.zvm/
222-
├── bin → symlink/junction to the active version directory
223-
├── .active → marker file tracking the active version name
224-
├── 0.16.0/ → Zig 0.16.0 installation
225-
│ └── zig → Zig compiler binary
226-
├── 0.14.0/ → Zig 0.14.0 installation
223+
~/.config/zvm/ ($XDG_CONFIG_HOME/zvm/)
224+
└── settings.json Configuration file
225+
226+
~/.local/share/zvm/ ($XDG_DATA_HOME/zvm/)
227+
├── bin → symlink/junction to the active version directory
228+
├── .active → marker file tracking the active version name
229+
├── 0.16.0/ → Zig 0.16.0 installation
230+
│ └── zig → Zig compiler binary
231+
├── 0.14.0/ → Zig 0.14.0 installation
227232
│ └── zig
228-
├── master/ → Latest nightly build
233+
├── master/ → Latest nightly build
229234
│ └── zig
230-
├── self/ → zvm's own data
231-
└── settings.json → Configuration file
235+
└── self/ → zvm's own data (upgrade staging)
236+
237+
~/.cache/zvm/ ($XDG_CACHE_HOME/zvm/)
238+
├── versions.json → cached Zig version map
239+
└── versions-zls.json → cached ZLS version map
232240
```
233241

234-
- **Version switching** uses symbolic links (junctions on Windows) — `~/.zvm/bin` points to the active version's directory
242+
- **Version switching** uses symbolic links (junctions on Windows) — the `bin` directory points to the active version's directory
235243
- **Downloads** are streamed to disk with SHA256 verification and latency-based mirror selection
236-
- **Settings** are persisted immediately on every change to `~/.zvm/settings.json`
244+
- **Settings** are persisted immediately on every change to `settings.json`
237245
- **No background services** — zvm runs only when you invoke it
238246

239247
## Configuration
240248

241-
Settings are stored in `~/.zvm/settings.json`:
249+
Settings are stored in `$XDG_CONFIG_HOME/zvm/settings.json` (default: `~/.config/zvm/settings.json`):
242250

243251
```json
244252
{
@@ -253,10 +261,31 @@ Settings are stored in `~/.zvm/settings.json`:
253261
}
254262
```
255263

256-
Override the default `~/.zvm` location with the `ZVM_PATH` environment variable:
264+
### Environment Variables
265+
266+
| Variable | Purpose |
267+
|----------|---------|
268+
| `ZVM_PATH` | Override the data directory (legacy, takes precedence over `XDG_DATA_HOME`) |
269+
| `XDG_CONFIG_HOME` | Config directory (default: `~/.config`) |
270+
| `XDG_DATA_HOME` | Data directory (default: `~/.local/share`) |
271+
| `XDG_CACHE_HOME` | Cache directory (default: `~/.cache`) |
272+
273+
### Migrating from `~/.zvm`
274+
275+
If you previously used zvm with the `~/.zvm` directory, migrate with:
276+
277+
```bash
278+
mkdir -p ~/.config/zvm ~/.local/share/zvm ~/.cache/zvm
279+
mv ~/.zvm/settings.json ~/.config/zvm/
280+
mv ~/.zvm/versions*.json ~/.cache/zvm/
281+
mv ~/.zvm/[0-9]* ~/.zvm/master ~/.zvm/bin ~/.zvm/.active ~/.local/share/zvm/
282+
rm -rf ~/.zvm
283+
```
284+
285+
Then update your shell config to use the new PATH:
257286

258287
```bash
259-
export ZVM_PATH="/custom/path"
288+
export PATH="${XDG_DATA_HOME:-$HOME/.local/share}/zvm/bin:$PATH"
260289
```
261290

262291
## CI / Releases
@@ -291,7 +320,7 @@ All available at `https://github.com/lispking/zvm/releases/latest/download/<file
291320
src/
292321
├── main.zig Entry point: allocator setup, CLI dispatch
293322
├── cli.zig Hand-written CLI parser with aliases and flags
294-
├── zvm.zig Core ZVM struct (base dir, settings, versions)
323+
├── zvm.zig Core ZVM struct (XDG dirs, settings, versions)
295324
├── settings.zig Settings persistence (JSON load/save)
296325
├── errors.zig Domain error definitions
297326
├── platform.zig OS/arch detection, symlink management

install.sh

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ set -euo pipefail
1010

1111
REPO="lispking/zvm"
1212
INSTALL_DIR="${ZVM_INSTALL:-$HOME/.local/bin}"
13-
ZVM_DIR="$HOME/.zvm"
13+
ZVM_DIR="${ZVM_PATH:-${XDG_DATA_HOME:-$HOME/.local/share}/zvm}"
1414

1515
# ── Colors ──────────────────────────────────────────────────────────────
1616
RED='\033[0;31m'
@@ -195,7 +195,7 @@ export PATH=\"\${HOME}/.local/bin:\$PATH\""
195195
fi
196196
if [ "$needs_zvm_bin" = true ]; then
197197
append_once "$shell_rc" "# <<< zvm <<<" \
198-
"export PATH=\"\${ZVM_DIR:-\$HOME/.zvm}/bin:\$PATH\"
198+
"export PATH=\"\${ZVM_PATH:-\${XDG_DATA_HOME:-\$HOME/.local/share}/zvm}/bin:\$PATH\"
199199
# <<< zvm <<<"
200200
fi
201201
if [ "$needs_local_bin" = true ] || [ "$needs_zvm_bin" = true ]; then
@@ -207,7 +207,7 @@ export PATH=\"\${HOME}/.local/bin:\$PATH\""
207207
warn "Add these lines to your shell config manually:"
208208
echo ""
209209
echo ' export PATH="$HOME/.local/bin:$PATH"'
210-
echo ' export PATH="$HOME/.zvm/bin:$PATH"'
210+
echo ' export PATH="${XDG_DATA_HOME:-$HOME/.local/share}/zvm/bin:$PATH"'
211211
echo ""
212212
fi
213213
fi
@@ -242,7 +242,7 @@ export PATH=\"\${HOME}/.local/bin:\$PATH\""
242242
echo " source ${shell_rc}"
243243
else
244244
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
245-
echo " export PATH=\"\$HOME/.zvm/bin:\$PATH\""
245+
echo " export PATH=\"\${XDG_DATA_HOME:-\$HOME/.local/share}/zvm/bin:\$PATH\""
246246
fi
247247
echo ""
248248
fi

src/clean.zig

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
//! Clean command — remove build artifacts from the zvm directory.
2-
//! Deletes downloaded .zip, .xz, .tar, and .tar.xz files from ~/.zvm/.
1+
//! Clean command — remove build artifacts from the cache directory.
2+
//! Deletes downloaded .zip, .xz, .tar, and .tar.xz files from the XDG cache dir.
33

44
const std = @import("std");
55
const zvm_mod = @import("zvm.zig");
66

7-
/// Remove archive files (.zip, .xz, .tar, .tar.xz) from the zvm base directory.
7+
/// Remove archive files (.zip, .xz, .tar, .tar.xz) from the cache directory.
88
/// These are leftover files from downloads that are no longer needed after extraction.
99
pub fn run(
1010
zvm: *zvm_mod.ZVM,
@@ -17,7 +17,7 @@ pub fn run(
1717

1818
const extensions = [_][]const u8{ ".zip", ".xz", ".tar", ".tar.xz" };
1919

20-
var dir = try std.Io.Dir.cwd().openDir(zvm.io, zvm.base_dir, .{ .iterate = true });
20+
var dir = try std.Io.Dir.cwd().openDir(zvm.io, zvm.cache_dir, .{ .iterate = true });
2121
defer dir.close(zvm.io);
2222

2323
var count: usize = 0;

src/cli.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ pub fn printCommandHelp(writer: *std.Io.Writer, cmd: Command) !void {
407407
\\
408408
),
409409
.clean => try writer.writeAll(
410-
\\Remove build artifacts from ~/.zvm/
410+
\\Remove build artifacts from the cache directory.
411411
\\
412412
\\Usage:
413413
\\ zvm clean

src/completion.zig

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ fn writeZshCompletion(writer: *std.Io.Writer) !void {
106106
\\
107107
\\_zvm_installed_versions() {
108108
\\ local -a versions
109-
\\ local zvm_dir="${ZVM_PATH:-$HOME/.zvm}"
109+
\\ local zvm_dir="${ZVM_PATH:-${XDG_DATA_HOME:-$HOME/.local/share}/zvm}"
110110
\\ if [[ -d "$zvm_dir" ]]; then
111111
\\ for dir in "$zvm_dir"/*/; do
112112
\\ local ver=$(basename "$dir")
@@ -222,7 +222,7 @@ fn writeBashCompletion(writer: *std.Io.Writer) !void {
222222
\\}
223223
\\
224224
\\_zvm_list_versions() {
225-
\\ local zvm_dir="${ZVM_PATH:-$HOME/.zvm}"
225+
\\ local zvm_dir="${ZVM_PATH:-${XDG_DATA_HOME:-$HOME/.local/share}/zvm}"
226226
\\ local -a versions=()
227227
\\ if [[ -d "$zvm_dir" ]]; then
228228
\\ for dir in "$zvm_dir"/*/; do

src/install.zig

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ fn installVersion(
105105
const archive_name = if (std.mem.lastIndexOfScalar(u8, tar_url, '/')) |idx| tar_url[idx + 1 ..] else "zig-archive";
106106

107107
var archive_path_buf: [std.fs.max_path_bytes * 2]u8 = undefined;
108-
const archive_path = try std.fmt.bufPrint(&archive_path_buf, "{s}/{s}", .{ zvm.base_dir, archive_name });
108+
const archive_path = try std.fmt.bufPrint(&archive_path_buf, "{s}/{s}", .{ zvm.cache_dir, archive_name });
109109

110110
// Download archive (with optional mirror support)
111111
try stdout.print("Downloading Zig {s}...\n", .{version});
@@ -151,7 +151,7 @@ fn installVersion(
151151
try stdout.print("Extracting...\n", .{});
152152
try stdout.flush();
153153

154-
archive.extractArchive(allocator, zvm.io, archive_path, zvm.base_dir) catch {
154+
archive.extractArchive(allocator, zvm.io, archive_path, zvm.data_dir) catch {
155155
try terminal.printError(stderr, "Failed to extract archive");
156156
return error.ExtractionFailed;
157157
};
@@ -218,7 +218,7 @@ fn renameExtractedDir(
218218
const arch_part = target[0..dash_idx];
219219
const os_part = target[dash_idx + 1 ..];
220220

221-
var dir = std.Io.Dir.cwd().openDir(zvm.io, zvm.base_dir, .{ .iterate = true }) catch return;
221+
var dir = std.Io.Dir.cwd().openDir(zvm.io, zvm.data_dir, .{ .iterate = true }) catch return;
222222
defer dir.close(zvm.io);
223223

224224
var found: ?[]const u8 = null;
@@ -251,15 +251,15 @@ fn renameExtractedDir(
251251

252252
/// Remove any leftover zig-* directories from failed or interrupted extractions.
253253
fn cleanupExtractedDirs(zvm: *zvm_mod.ZVM) void {
254-
var dir = std.Io.Dir.cwd().openDir(zvm.io, zvm.base_dir, .{ .iterate = true }) catch return;
254+
var dir = std.Io.Dir.cwd().openDir(zvm.io, zvm.data_dir, .{ .iterate = true }) catch return;
255255
defer dir.close(zvm.io);
256256

257257
var iter = dir.iterate();
258258
while (iter.next(zvm.io) catch return) |entry| {
259259
if (entry.kind != .directory) continue;
260260
if (std.mem.startsWith(u8, entry.name, "zig-")) {
261261
var buf: [std.fs.max_path_bytes * 2]u8 = undefined;
262-
const path = std.fmt.bufPrint(&buf, "{s}/{s}", .{ zvm.base_dir, entry.name }) catch continue;
262+
const path = std.fmt.bufPrint(&buf, "{s}/{s}", .{ zvm.data_dir, entry.name }) catch continue;
263263
std.Io.Dir.cwd().deleteTree(zvm.io, path) catch {};
264264
}
265265
}
@@ -278,9 +278,9 @@ fn verifyInstall(
278278
const zig_path = try std.fmt.allocPrint(allocator, "{s}/zig", .{ver_path});
279279
defer allocator.free(zig_path);
280280

281-
// Create a temporary test file in the zvm base directory
281+
// Create a temporary test file in the cache directory
282282
var test_buf: [std.fs.max_path_bytes * 2]u8 = undefined;
283-
const test_path = try std.fmt.bufPrint(&test_buf, "{s}/_zvm_smoke_test.zig", .{zvm.base_dir});
283+
const test_path = try std.fmt.bufPrint(&test_buf, "{s}/_zvm_smoke_test.zig", .{zvm.cache_dir});
284284
defer std.Io.Dir.cwd().deleteFile(zvm.io, test_path) catch {};
285285

286286
const test_file = try std.Io.Dir.cwd().createFile(zvm.io, test_path, .{});
@@ -407,13 +407,13 @@ fn installZls(
407407
const zls_archive_name = if (std.mem.lastIndexOfScalar(u8, zls_tarball, '/')) |idx| zls_tarball[idx + 1 ..] else "zls-archive";
408408

409409
var archive_buf: [std.fs.max_path_bytes * 2]u8 = undefined;
410-
const zls_archive_path = try std.fmt.bufPrint(&archive_buf, "{s}/{s}", .{ zvm.base_dir, zls_archive_name });
410+
const zls_archive_path = try std.fmt.bufPrint(&archive_buf, "{s}/{s}", .{ zvm.cache_dir, zls_archive_name });
411411

412412
try http_client.downloadToFileWithProxy(allocator, zvm.io, zvm.environ_map, zls_tarball, zls_archive_path, zvm.settings.proxy);
413413

414414
// Extract to a temporary directory
415415
var temp_buf: [std.fs.max_path_bytes * 2]u8 = undefined;
416-
const temp_dir = try std.fmt.bufPrint(&temp_buf, "{s}/zls-temp", .{zvm.base_dir});
416+
const temp_dir = try std.fmt.bufPrint(&temp_buf, "{s}/zls-temp", .{zvm.cache_dir});
417417
std.Io.Dir.cwd().createDirPath(zvm.io, temp_dir) catch {};
418418

419419
archive.extractArchive(allocator, zvm.io, zls_archive_path, temp_dir) catch {
@@ -437,12 +437,12 @@ fn installZls(
437437
var inner_iter = inner_dir.iterate();
438438
while (try inner_iter.next(zvm.io)) |inner_entry| {
439439
if (std.mem.eql(u8, inner_entry.name, "zls") or std.mem.eql(u8, inner_entry.name, "zls.exe")) {
440-
// Copy the zls binary to ~/.zvm/<version>/zls
440+
// Copy the zls binary to data_dir/<version>/zls
441441
var src_buf: [std.fs.max_path_bytes * 2]u8 = undefined;
442442
const src = try std.fmt.bufPrint(&src_buf, "{s}/{s}/{s}", .{ temp_dir, entry.name, inner_entry.name });
443443

444444
var dst_buf: [std.fs.max_path_bytes * 2]u8 = undefined;
445-
const dst = try std.fmt.bufPrint(&dst_buf, "{s}/{s}/zls", .{ zvm.base_dir, version });
445+
const dst = try std.fmt.bufPrint(&dst_buf, "{s}/{s}/zls", .{ zvm.data_dir, version });
446446

447447
const src_file = std.Io.Dir.cwd().openFile(zvm.io, src, .{}) catch continue;
448448
defer src_file.close(zvm.io);

src/main.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ pub fn main(init: std.process.Init) !void {
3838
std.process.exit(1);
3939
};
4040

41-
// Initialize ZVM environment (~/.zvm, settings, etc.)
41+
// Initialize ZVM environment (XDG dirs, settings, etc.)
4242
var zvm = zvm_mod.ZVM.init(allocator, init.io, init.environ_map) catch {
4343
var stderr_buf: [4096]u8 = undefined;
4444
var stderr_writer = std.Io.File.stderr().writer(init.io, &stderr_buf);

src/platform.zig

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,9 @@ pub fn removeSymlink(io: std.Io, path: []const u8) void {
103103
std.Io.Dir.cwd().deleteFile(io, path) catch {};
104104
}
105105

106-
/// Resolve the user's home directory.
107-
/// Checks ZVM_PATH env var first, then falls back to HOME (or USERPROFILE on Windows).
106+
/// Resolve the home directory (used as fallback for XDG defaults).
108107
/// Caller owns the returned memory.
109-
pub fn getHomeDir(allocator: std.mem.Allocator, environ_map: *std.process.Environ.Map) ![]const u8 {
110-
// Try ZVM_PATH first, then HOME
111-
if (environ_map.get("ZVM_PATH")) |path| {
112-
return allocator.dupe(u8, path);
113-
}
114-
108+
fn getHomeDir(allocator: std.mem.Allocator, environ_map: *std.process.Environ.Map) ![]const u8 {
115109
if (builtin.os.tag == .windows) {
116110
if (environ_map.get("USERPROFILE")) |path| {
117111
return allocator.dupe(u8, path);
@@ -124,6 +118,66 @@ pub fn getHomeDir(allocator: std.mem.Allocator, environ_map: *std.process.Enviro
124118
return error.FileNotFound;
125119
}
126120

121+
/// Resolve the XDG config directory.
122+
/// Checks XDG_CONFIG_HOME, falls back to $HOME/.config.
123+
/// On Windows, falls back to %APPDATA% or %USERPROFILE%/.config.
124+
/// Caller owns the returned memory.
125+
pub fn getConfigDir(allocator: std.mem.Allocator, environ_map: *std.process.Environ.Map) ![]const u8 {
126+
if (environ_map.get("XDG_CONFIG_HOME")) |path| {
127+
if (path.len > 0) return allocator.dupe(u8, path);
128+
}
129+
130+
if (builtin.os.tag == .windows) {
131+
if (environ_map.get("APPDATA")) |path| {
132+
if (path.len > 0) return allocator.dupe(u8, path);
133+
}
134+
}
135+
136+
const home = try getHomeDir(allocator, environ_map);
137+
defer allocator.free(home);
138+
return std.fmt.allocPrint(allocator, "{s}/.config", .{home});
139+
}
140+
141+
/// Resolve the XDG data directory.
142+
/// Checks ZVM_PATH (legacy override) first, then XDG_DATA_HOME,
143+
/// falls back to $HOME/.local/share.
144+
/// On Windows, falls back to %USERPROFILE%/.local/share.
145+
/// Caller owns the returned memory.
146+
pub fn getDataDir(allocator: std.mem.Allocator, environ_map: *std.process.Environ.Map) ![]const u8 {
147+
// Legacy ZVM_PATH override — use as-is (user already specifies full path)
148+
if (environ_map.get("ZVM_PATH")) |path| {
149+
if (path.len > 0) return allocator.dupe(u8, path);
150+
}
151+
152+
if (environ_map.get("XDG_DATA_HOME")) |path| {
153+
if (path.len > 0) return allocator.dupe(u8, path);
154+
}
155+
156+
const home = try getHomeDir(allocator, environ_map);
157+
defer allocator.free(home);
158+
return std.fmt.allocPrint(allocator, "{s}/.local/share", .{home});
159+
}
160+
161+
/// Resolve the XDG cache directory.
162+
/// Checks XDG_CACHE_HOME, falls back to $HOME/.cache.
163+
/// On Windows, falls back to %LOCALAPPDATA% or %USERPROFILE%/.cache.
164+
/// Caller owns the returned memory.
165+
pub fn getCacheDir(allocator: std.mem.Allocator, environ_map: *std.process.Environ.Map) ![]const u8 {
166+
if (environ_map.get("XDG_CACHE_HOME")) |path| {
167+
if (path.len > 0) return allocator.dupe(u8, path);
168+
}
169+
170+
if (builtin.os.tag == .windows) {
171+
if (environ_map.get("LOCALAPPDATA")) |path| {
172+
if (path.len > 0) return allocator.dupe(u8, path);
173+
}
174+
}
175+
176+
const home = try getHomeDir(allocator, environ_map);
177+
defer allocator.free(home);
178+
return std.fmt.allocPrint(allocator, "{s}/.cache", .{home});
179+
}
180+
127181
/// Build the target-specific platform string used in Zig download URLs.
128182
/// E.g., "x86_64-macos", "aarch64-linux"
129183
pub fn platformTarget(buf: []u8, info: SystemInfo) []const u8 {

src/run.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ pub fn run(
2626
std.process.exit(1);
2727
}
2828

29-
// Build the zig binary path: ~/.zvm/<version>/zig
29+
// Build the zig binary path: data_dir/<version>/zig
3030
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
3131
const version_dir = zvm.versionPath(&path_buf, version);
3232
const zig_path = try std.fmt.allocPrint(allocator, "{s}/zig", .{version_dir});

src/settings.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//! Settings persistence for zvm.
2-
//! Manages the JSON configuration file (~/.zvm/settings.json) with
2+
//! Manages the JSON configuration file ($XDG_CONFIG_HOME/zvm/settings.json) with
33
//! eager persistence — every mutation immediately writes to disk.
44

55
const std = @import("std");

0 commit comments

Comments
 (0)