zv 0.10.0

Ziglang Version Manager and Project Starter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
use crate::ZvError;
use color_eyre::{
    Result,
    eyre::{WrapErr, bail, eyre},
};
use semver::Version;
use std::{
    borrow::Cow,
    io,
    path::{Path, PathBuf},
};
use yansi::Paint;

/// Central struct for all zv path resolution.
/// Provides a single source of truth for the directory layout, enabling XDG compliance
/// on Linux/macOS while falling back to `~/.zv` on Windows.
#[derive(Debug, Clone)]
pub struct ZvPaths {
    /// Primary data directory: `XDG_DATA_HOME/zv` (`~/.local/share/zv`) or `~/.zv` on Windows
    pub data_dir: PathBuf,
    /// Internal binary dir (`data_dir/bin`) — actual zv binary and shims
    pub bin_dir: PathBuf,
    /// Installed zig versions (`data_dir/versions`)
    pub versions_dir: PathBuf,
    /// Config directory: `XDG_CONFIG_HOME/zv` (`~/.config/zv`) or `data_dir` on Windows
    pub config_dir: PathBuf,
    /// Active config file (`config_dir/zv.toml`)
    pub config_file: PathBuf,
    /// Cache directory: `XDG_CACHE_HOME/zv` (`~/.cache/zv`) or `data_dir` on Windows
    #[allow(dead_code)]
    pub cache_dir: PathBuf,
    /// Download cache (`cache_dir/downloads`)
    pub downloads_dir: PathBuf,
    /// Cached zig version index (`cache_dir/index.toml`)
    pub index_file: PathBuf,
    /// Cached mirrors list (`cache_dir/mirrors.toml`)
    pub mirrors_file: PathBuf,
    /// Cached master version string (`cache_dir/master`)
    pub master_file: PathBuf,
    /// Public bin dir for XDG symlinks (`~/.local/bin`). `None` on Windows.
    pub public_bin_dir: Option<PathBuf>,
    /// Whether `ZV_DIR` was set via environment variable
    pub using_env_var: bool,
    /// Deployment tier: 1 = XDG (no setup needed), 2 = macOS Library (PATH injection), 3 = ~/.zv fallback
    #[cfg(target_os = "macos")]
    pub tier: u8,
}

impl ZvPaths {
    /// Resolve all zv paths applying XDG Base Directory conventions on Linux/macOS.
    /// On Windows, all paths fall back to `~/.zv` (same as existing behaviour).
    ///
    /// When `ZV_DIR` is set via environment variable it overrides `data_dir` only;
    /// `config_dir` and `cache_dir` still follow XDG (or fall back to `data_dir` on Windows).
    pub fn resolve() -> Result<Self> {
        let (data_dir, using_env_var) = fetch_zv_dir()?;

        // When ZV_DIR is explicitly set, treat it as a self-contained root (pre-XDG layout).
        // XDG splitting only applies when the user has not expressed an opinion via ZV_DIR.
        #[cfg(not(windows))]
        let (config_dir, cache_dir, public_bin_dir) = if using_env_var {
            (data_dir.clone(), data_dir.clone(), None)
        } else {
            #[cfg(not(target_os = "macos"))]
            {
                let config = xdg_config_home()
                    .unwrap_or_else(|_| data_dir.clone())
                    .join("zv");
                let cache = xdg_cache_home()
                    .unwrap_or_else(|_| data_dir.clone())
                    .join("zv");
                let public_bin = xdg_bin_home().ok();
                (config, cache, public_bin)
            }
            #[cfg(target_os = "macos")]
            if xdg_dirs_exist() {
                let config = xdg_config_home()
                    .unwrap_or_else(|_| data_dir.clone())
                    .join("zv");
                let cache = xdg_cache_home()
                    .unwrap_or_else(|_| data_dir.clone())
                    .join("zv");
                let public_bin = xdg_bin_home().ok();
                (config, cache, public_bin)
            } else {
                let home = home_dir()?;
                let base = home.join("Library/Application Support/zv");
                let cache = home.join("Library/Caches/zv");
                let public_bin = Some(base.join("bin"));
                (base.clone(), cache, public_bin)
            }
        };

        #[cfg(windows)]
        let (config_dir, cache_dir, public_bin_dir) = {
            // Windows: keep everything under data_dir, no public bin
            (data_dir.clone(), data_dir.clone(), None)
        };

        #[cfg(target_os = "macos")]
        let tier = if using_env_var {
            3
        } else if xdg_dirs_exist() {
            1
        } else {
            2
        };

        Ok(Self {
            bin_dir: data_dir.join("bin"),
            versions_dir: data_dir.join("versions"),
            config_file: config_dir.join("zv.toml"),
            downloads_dir: cache_dir.join("downloads"),
            index_file: cache_dir.join("index.toml"),
            mirrors_file: cache_dir.join("mirrors.toml"),
            master_file: cache_dir.join("master"),
            public_bin_dir,
            config_dir,
            cache_dir,
            data_dir,
            using_env_var,
            #[cfg(target_os = "macos")]
            tier,
        })
    }

    /// Default env file path (`data_dir/env`) when shell type is unknown.
    pub fn env_file_default(&self) -> PathBuf {
        self.data_dir.join("env")
    }
}

// ── XDG helpers ──────────────────────────────────────────────────────────────

#[cfg(target_os = "macos")]
fn xdg_dirs_exist() -> bool {
    home_dir()
        .map(|h| h.join(".local/share").is_dir() && h.join(".local/bin").is_dir())
        .unwrap_or(false)
}

/// `$XDG_DATA_HOME` → defaults to `$HOME/.local/share`
fn xdg_data_home() -> Result<PathBuf> {
    if let Ok(val) = std::env::var("XDG_DATA_HOME") {
        if !val.is_empty() {
            return Ok(PathBuf::from(val));
        }
    }
    home_dir().map(|h| h.join(".local/share"))
}

/// `$XDG_CONFIG_HOME` → defaults to `$HOME/.config`
fn xdg_config_home() -> Result<PathBuf> {
    if let Ok(val) = std::env::var("XDG_CONFIG_HOME") {
        if !val.is_empty() {
            return Ok(PathBuf::from(val));
        }
    }
    home_dir().map(|h| h.join(".config"))
}

/// `$XDG_CACHE_HOME` → defaults to `$HOME/.cache`
fn xdg_cache_home() -> Result<PathBuf> {
    if let Ok(val) = std::env::var("XDG_CACHE_HOME") {
        if !val.is_empty() {
            return Ok(PathBuf::from(val));
        }
    }
    home_dir().map(|h| h.join(".cache"))
}

/// `$XDG_BIN_HOME` → defaults to `$HOME/.local/bin` (informal convention)
fn xdg_bin_home() -> Result<PathBuf> {
    if let Ok(val) = std::env::var("XDG_BIN_HOME") {
        if !val.is_empty() {
            return Ok(PathBuf::from(val));
        }
    }
    home_dir().map(|h| h.join(".local/bin"))
}

/// Resolve the user's home directory via shell detection
fn home_dir() -> Result<PathBuf> {
    let shell = crate::shell::Shell::detect();
    shell
        .get_home_dir()
        .ok_or_else(|| eyre!("Unable to locate home directory"))
}

/// Cross-platform canonicalize function that avoids UNC paths on Windows
pub fn canonicalize<P: AsRef<Path>>(path: P) -> io::Result<PathBuf> {
    dunce::canonicalize(path)
}

/// Check if we're running in a TTY environment
#[inline]
pub(crate) fn is_tty() -> bool {
    yansi::is_enabled()
}

/// Check if the current environment supports interactive prompts
pub(crate) fn supports_interactive_prompts() -> bool {
    // Check basic TTY availability
    if !is_tty() {
        return false;
    }

    // Check for CI environments
    if std::env::var("CI").is_ok() {
        return false;
    }

    // Check for non-interactive terminals
    if let Ok(term) = std::env::var("TERM")
        && term == "dumb"
    {
        return false;
    }

    // Additional environment checks
    if std::env::var("DEBIAN_FRONTEND").as_deref() == Ok("noninteractive") {
        return false;
    }

    // For now, rely on yansi's TTY detection which handles most cases
    true
}

/// Macro to print standardized solution suggestions with bullet points
///
/// Usage:
/// ```
/// suggest!("You can install a compatible Zig version with {}", cmd = "zv use <version>");
/// suggest!("Make sure you've run {}", cmd = "zv setup");
/// suggest!("Simple message without command");
/// ```
#[macro_export]
macro_rules! suggest {
    // Pattern with cmd parameter
    ($fmt:expr, cmd = $cmd:expr $(, $($args:tt)*)?) => {
        println!(
            "• {}",
            format!($fmt, $crate::tools::format_cmd($cmd) $(, $($args)*)?)
        );
    };
    // Pattern without cmd parameter
    ($fmt:expr $(, $($args:tt)*)?) => {
        println!("{}", format!($fmt $(, $($args)*)?));
    };
}

/// Helper function to format commands with green italic styling
pub fn format_cmd(cmd: &str) -> String {
    Paint::green(cmd).italic().to_string()
}

/// Fetch the zv directory PATH set using env var or fallback PATH ($HOME/.zv)
/// This function also handles the initialization and creation of the ZV_DIR if it doesn't exist
/// Returns a canonicalized PathBuf and a bool indicating if the path was set via env var
pub(crate) fn fetch_zv_dir() -> Result<(PathBuf, bool)> {
    let zv_dir_env = match std::env::var("ZV_DIR") {
        Ok(dir) if !dir.is_empty() => Some(dir),
        Ok(_) => None,
        Err(env_err) => match env_err {
            std::env::VarError::NotPresent => None,
            std::env::VarError::NotUnicode(ref str) => {
                error(format!(
                    "Warning: ZV_DIR={str:?} is set but contains invalid Unicode."
                ));
                return Err(eyre!(env_err));
            }
        },
    };

    let (zv_dir, using_env) = if let Some(zv_dir) = zv_dir_env {
        (PathBuf::from(zv_dir), true /* using-env true */)
    } else {
        (get_default_zv_dir()?, false /* Using fallback path */)
    };

    // Init ZV_DIR - create it if it doesn't exist
    match zv_dir.try_exists() {
        Ok(true) => {
            if !zv_dir.is_dir() {
                error(format!(
                    "zv directory exists but is not a directory: {}. Please check ZV_DIR env var. Aborting...",
                    zv_dir.display()
                ));
                bail!(eyre!("ZV_DIR exists but is not a directory"));
            }
        }
        Ok(false) => {
            if using_env {
                std::fs::create_dir_all(&zv_dir)
                    .map_err(ZvError::Io)
                    .wrap_err_with(|| {
                        format!(
                            "Error creating ZV_DIR from env var ZV_DIR={}",
                            std::env::var("ZV_DIR").expect("Handled in fetch_zv_dir()")
                        )
                    })?;
            } else {
                // create_dir should be enough for default directory
                std::fs::create_dir(&zv_dir)
                    .map_err(ZvError::Io)
                    .wrap_err_with(|| {
                        format!("Failed to create default .zv at {}", zv_dir.display())
                    })?;
            }
        }
        Err(e) => {
            error(format!(
                "Failed to check zv directory at {:?}",
                zv_dir.display(),
            ));
            return Err(ZvError::Io(e).into());
        }
    };

    // Canonicalize the path before returning
    let zv_dir = canonicalize(&zv_dir).map_err(ZvError::Io)?;

    Ok((zv_dir, using_env))
}

/// Get the default ZV data directory.
/// On Linux/macOS: `$XDG_DATA_HOME/zv` (defaults to `~/.local/share/zv`).
/// On Windows: `~/.zv` (unchanged).
pub(crate) fn get_default_zv_dir() -> Result<PathBuf> {
    #[cfg(not(windows))]
    {
        xdg_data_home().map(|d| d.join("zv"))
    }
    #[cfg(windows)]
    {
        home_dir().map(|h| h.join(".zv"))
    }
}

/// Print a warning message in yellow if stderr is a TTY
#[inline]
pub fn warn(message: impl Into<Cow<'static, str>>) {
    let msg = message.into();
    eprintln!("{}: {}", "Warning".yellow().bold(), msg);
}

/// Print an error message in red if stderr is a TTY
#[inline]
pub fn error(message: impl Into<Cow<'static, str>>) {
    let msg = message.into();
    eprintln!("{}: {}", "Error".red().bold(), msg);
}

/// Calculate CRC32 hash of a file
pub fn calculate_file_hash(path: &Path) -> Result<u32> {
    use crc32fast::Hasher;
    use std::io::Read;

    let mut file = std::fs::File::open(path)
        .wrap_err_with(|| format!("Failed to open file for hashing: {}", path.display()))?;

    let mut hasher = Hasher::new();
    let mut buffer = [0; 8192]; // 8KB buffer

    loop {
        let bytes_read = file
            .read(&mut buffer)
            .wrap_err_with(|| format!("Failed to read file for hashing: {}", path.display()))?;

        if bytes_read == 0 {
            break;
        }

        hasher.update(&buffer[..bytes_read]);
    }

    Ok(hasher.finalize())
}

/// Compare file hashes to determine if files are identical
pub fn files_have_same_hash(path1: &Path, path2: &Path) -> Result<bool> {
    if !path1.exists() || !path2.exists() {
        return Ok(false);
    }

    Ok(calculate_file_hash(path1)? == calculate_file_hash(path2)?)
}
/// Build.zig.zon files have a .name field that expect an enum literal v0.13 onwards
/// 0.12 expects a string literal. 0.11 and below don't come with build.zig.zon files.
pub fn sanitize_build_zig_zon_name(name: Option<&str>, zig_version: &Version) -> Option<String> {
    if *zig_version < Version::new(0, 12, 0) {
        return None; // build.zig.zon not supported below 0.12
    }

    // Default .name
    let default_name = "app";

    // Extract and clean provided name
    let raw = name.unwrap_or(default_name).trim();

    // Basic normalization: lowercase and replace invalid chars
    let mut sanitized = raw
        .chars()
        .map(|c| match c {
            'a'..='z' | 'A'..='Z' | '0'..='9' | '_' => c,
            '-' | ' ' | '.' => '_',
            _ => '_', // fallback for all invalid chars
        })
        .collect::<String>()
        .to_lowercase();

    // Must not start with a digit
    if let Some(first_char) = sanitized.chars().next()
        && first_char.is_ascii_digit()
    {
        sanitized = format!("_{}", sanitized);
    }
    // Check Zig version to decide output form
    Some(if *zig_version >= Version::new(0, 13, 0) {
        format!(".{sanitized}") // enum literal preferred from v0.13..
    } else {
        format!("\"{sanitized}\"") // only v0.12
    })
}

/// Deduplicate semver variants before resolution
///
/// This function handles cases where the user specifies the same version in different forms:
/// - `0.14.0` and `latest@0.14.0` and `stable@0.14.0` all become just `0.14.0`
/// - Duplicate exact versions like `0.14.0, 0.14.0, 0.14.0` become just a single `0.14.0`
pub fn deduplicate_semver_variants(versions: Vec<crate::ZigVersion>) -> Vec<crate::ZigVersion> {
    let mut seen_semvers: std::collections::HashMap<semver::Version, crate::ZigVersion> =
        std::collections::HashMap::new();
    let mut non_semver_versions: Vec<crate::ZigVersion> = Vec::new();

    for version in versions {
        match version {
            // For variants with explicit semver, deduplicate by the semver
            crate::ZigVersion::Semver(v) => {
                seen_semvers
                    .entry(v.clone())
                    .or_insert(crate::ZigVersion::Semver(v));
            }
            crate::ZigVersion::Latest(Some(v)) | crate::ZigVersion::Stable(Some(v)) => {
                // Prefer plain semver over latest@version or stable@version
                seen_semvers
                    .entry(v.clone())
                    .or_insert(crate::ZigVersion::Semver(v));
            }
            // Non-semver versions (latest, stable, master) need resolution to deduplicate
            crate::ZigVersion::Latest(None)
            | crate::ZigVersion::Stable(None)
            | crate::ZigVersion::Master(_) => {
                non_semver_versions.push(version);
            }
        }
    }

    // Combine deduplicated semvers with non-semver versions
    let mut result: Vec<crate::ZigVersion> = seen_semvers.into_values().collect();
    result.extend(non_semver_versions);
    result
}