alef 0.25.37

Opinionated polyglot binding generator for Rust libraries
Documentation
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
464
465
466
467
468
469
470
471
472
473
474
475
476
477
use super::*;

pub(super) fn detect_stale_zig_hash(hash: &str, current_version: &str, pkg_name: &str) -> bool {
    // Hash format: `{pkg_name}-{version}-{multihash}`
    // Example: `demo_client-1.4.0-rc.50-Jfgk_NcsAQBpkv3XrckgE9vZmwDERDOandv0Ud6LXpHH`
    let prefix = format!("{pkg_name}-");
    if !hash.starts_with(&prefix) {
        return false;
    }

    // Remove the crate name prefix and split the rest by dashes.
    let rest = &hash[prefix.len()..];
    let parts: Vec<&str> = rest.split('-').collect();

    // Reconstruct the version by iterating through parts until we hit
    // the hash-like segment (long alphanumeric or underscore string).
    let mut version_parts: Vec<&str> = Vec::new();
    for (i, part) in parts.iter().enumerate() {
        // Last part is always the hash; don't include it.
        if i == parts.len() - 1 {
            break;
        }

        version_parts.push(part);

        // Heuristic: if this part looks like a hash (>20 chars or contains underscores/alphanumerics),
        // and we've accumulated at least one version part, stop here.
        if part.len() > 20 || (part.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && i > 0) {
            // This is likely the hash segment; remove it from version_parts.
            version_parts.pop();
            break;
        }
    }

    let embedded_version = version_parts.join("-");

    if embedded_version != current_version {
        tracing::warn!(
            "zig package hash mismatch: hash contains version '{}', but current version is '{}'; \
             regenerate with `alef sync-versions`",
            embedded_version,
            current_version
        );
        return true;
    }

    false
}

/// Path to the on-disk hash cache: `~/.cache/alef/zig-hashes.json` on Unix /
/// `%LOCALAPPDATA%\alef\zig-hashes.json` on Windows.
///
/// Returns `None` when the home / local-app-data environment variable is unset.
fn zig_hash_cache_path() -> Option<std::path::PathBuf> {
    // XDG_CACHE_HOME takes precedence on Linux.
    if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
        if !xdg.is_empty() {
            return Some(std::path::PathBuf::from(xdg).join("alef").join("zig-hashes.json"));
        }
    }
    // macOS and Linux: $HOME/.cache/alef/zig-hashes.json
    if let Ok(home) = std::env::var("HOME") {
        if !home.is_empty() {
            return Some(
                std::path::PathBuf::from(home)
                    .join(".cache")
                    .join("alef")
                    .join("zig-hashes.json"),
            );
        }
    }
    // Windows: %LOCALAPPDATA%\alef\zig-hashes.json
    if let Ok(local_app) = std::env::var("LOCALAPPDATA") {
        if !local_app.is_empty() {
            return Some(std::path::PathBuf::from(local_app).join("alef").join("zig-hashes.json"));
        }
    }
    None
}

/// Read the (URL → hash) cache. Returns an empty map on any I/O error.
fn read_zig_hash_cache() -> std::collections::HashMap<String, String> {
    let Some(path) = zig_hash_cache_path() else {
        return std::collections::HashMap::new();
    };
    let Ok(bytes) = std::fs::read(&path) else {
        return std::collections::HashMap::new();
    };
    serde_json::from_slice(&bytes).unwrap_or_default()
}

/// Persist a single (url → hash) entry into the cache.
fn write_zig_hash_cache_entry(url: &str, hash: &str) {
    let Some(path) = zig_hash_cache_path() else {
        return;
    };
    let mut map = read_zig_hash_cache();
    map.insert(url.to_string(), hash.to_string());
    let Ok(json) = serde_json::to_string_pretty(&map) else {
        return;
    };
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).ok();
    }
    std::fs::write(&path, json).ok();
}

/// Fetch the content multihash for a Zig package tarball URL by shelling out
/// to `zig fetch <url>` from a scratch directory.
///
/// Returns the hash string (printed by `zig fetch` on stdout) on success, or
/// `None` when `zig fetch` is unavailable / returns a non-zero exit code /
/// produces no recognisable hash output.
fn fetch_zig_hash_from_network(url: &str) -> Option<String> {
    let tmp = tempfile::tempdir().ok()?;
    // Write a minimal stub build.zig.zon so `zig fetch` has a valid package
    // context to operate from. Without it, older zig versions refuse to run.
    let stub = r#".{
    .name = .zig_hash_fetch_stub,
    .version = "0.0.0",
    .fingerprint = 0x0000000000000001,
    .dependencies = .{},
    .paths = .{"build.zig.zon"},
}
"#;
    std::fs::write(tmp.path().join("build.zig.zon"), stub).ok()?;
    // `zig fetch <url>` (hash-only, no `--save`) still aborts with "no build.zig
    // file found" unless a build.zig exists in the directory tree, so write a
    // no-op one alongside the manifest.
    std::fs::write(
        tmp.path().join("build.zig"),
        "pub fn build(b: *@import(\"std\").Build) void {\n    _ = b;\n}\n",
    )
    .ok()?;

    let output = std::process::Command::new("zig")
        .arg("fetch")
        .arg(url)
        .current_dir(tmp.path())
        .output()
        .ok()?;

    if !output.status.success() {
        return None;
    }

    // `zig fetch` prints the content multihash on stdout as a single line.
    let stdout = String::from_utf8_lossy(&output.stdout);
    stdout
        .lines()
        .map(|l| l.trim())
        .find(|l| !l.is_empty())
        .map(|s| s.to_string())
}

/// Resolve the content multihash for a Zig registry tarball URL.
///
/// Resolution order:
/// 1. `explicit` — a `hash` value set directly in `alef.toml` under
///    `[crates.e2e.registry.packages.zig]`. Takes precedence over everything.
/// 2. Cache — `~/.cache/alef/zig-hashes.json` keyed by URL.
/// 3. Network — shells out to `zig fetch <url>`, parses the printed hash,
///    writes the result back to the cache, and returns it.
/// 4. Fallback — logs a warning and returns `None`. Registry generation
///    requires an explicit hash before calling this helper, so this path is
///    only available to tests and non-publishable dry-run callers.
pub(super) fn resolve_zig_hash(explicit: Option<&str>, url: &str) -> Option<String> {
    // 1. Explicit override wins.
    if let Some(h) = explicit {
        return Some(h.to_string());
    }

    // 2. On-disk cache.
    let cache = read_zig_hash_cache();
    if let Some(h) = cache.get(url) {
        return Some(h.clone());
    }

    // 3. Network fetch.
    match fetch_zig_hash_from_network(url) {
        Some(h) => {
            write_zig_hash_cache_entry(url, &h);
            Some(h)
        }
        None => {
            tracing::warn!(
                "zig hash skipped — asset {} not yet published; regen after release",
                url
            );
            None
        }
    }
}

pub(super) fn supported_zig_platforms() -> &'static [&'static str] {
    &[
        "aarch64-unknown-linux-gnu",
        "aarch64-apple-darwin",
        "x86_64-unknown-linux-gnu",
        "x86_64-apple-darwin",
        "x86_64-pc-windows-msvc",
    ]
}

pub(super) fn uses_platform_registry_deps(platform_hashes: &BTreeMap<String, (String, Option<String>)>) -> bool {
    platform_hashes.keys().any(|platform| platform != "generic")
}

#[cfg(test)]
mod zig_hash_tests {
    use super::resolve_zig_hash;
    use crate::e2e::codegen::zig::build::render_build_zig_zon;
    use crate::e2e::config::DependencyMode;

    /// When an explicit hash is supplied via alef.toml it must be emitted
    /// verbatim — no network fetch, no cache lookup.
    #[test]
    fn explicit_hash_override_is_used_verbatim() {
        let url = "https://example.invalid/example-org/demo-client/releases/download/v1.4.0/demo-client-zig-v1.4.0-linux-x86_64.tar.gz";
        let pinned = "1220abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab";
        let result = resolve_zig_hash(Some(pinned), url);
        assert_eq!(
            result.as_deref(),
            Some(pinned),
            "explicit hash must be returned unchanged; got: {result:?}"
        );
    }

    /// When the explicit hash is used it must be emitted in build.zig.zon (single generic tarball).
    #[test]
    fn build_zig_zon_emits_explicit_hash() {
        let hash = "12208badf00d";
        let mut platform_hashes = std::collections::BTreeMap::new();
        let url =
            "https://example.invalid/example-org/demo-client/releases/download/v1.4.0-rc.32/demo-client-zig-v1.4.0-rc.32.tar.gz"
                .to_string();
        platform_hashes.insert("generic".to_string(), (url, Some(hash.to_string())));
        let content = render_build_zig_zon(
            "demo_client",
            "../../packages/zig",
            DependencyMode::Registry,
            "1.4.0-rc.32",
            &platform_hashes,
            false,
        );
        assert!(
            content.contains(&format!(".hash = \"{hash}\"")),
            "build.zig.zon must embed the explicit hash, got:\n{content}"
        );
        assert!(
            !content.contains(".hash = \"PLACEHOLDER\""),
            "build.zig.zon must not emit a placeholder hash when hash is provided, got:\n{content}"
        );
        // Verify the single generic (no-suffix) URL is present.
        assert!(
            content.contains("demo-client-zig-v1.4.0-rc.32.tar.gz"),
            "build.zig.zon must emit the generic source tarball URL (no platform suffix), got:\n{content}"
        );
    }

    #[test]
    fn build_zig_zon_emits_platform_hashes_as_lazy_dependencies() {
        let mut platform_hashes = std::collections::BTreeMap::new();
        platform_hashes.insert(
            "x86_64-unknown-linux-gnu".to_string(),
            (
                "https://example.invalid/example-org/sample-lib/releases/download/v1.2.3/sample-lib-zig-v1.2.3-x86_64-unknown-linux-gnu.tar.gz"
                    .to_string(),
                Some("1220linux".to_string()),
            ),
        );
        platform_hashes.insert(
            "aarch64-apple-darwin".to_string(),
            (
                "https://example.invalid/example-org/sample-lib/releases/download/v1.2.3/sample-lib-zig-v1.2.3-aarch64-apple-darwin.tar.gz"
                    .to_string(),
                Some("1220macos".to_string()),
            ),
        );

        let content = render_build_zig_zon(
            "sample_lib",
            "../../packages/zig",
            DependencyMode::Registry,
            "1.2.3",
            &platform_hashes,
            false,
        );

        assert!(content.contains(".sample_lib_x86_64_unknown_linux_gnu"));
        assert!(content.contains(".sample_lib_aarch64_apple_darwin"));
        assert!(content.contains(".lazy = true"));
        assert!(content.contains(".hash = \"1220linux\""));
        assert!(content.contains(".hash = \"1220macos\""));
        assert!(
            !content.contains(".sample_lib = .{"),
            "platform-specific registry mode must not also emit a generic dependency: {content}"
        );
    }

    /// When no hash is available (None), no fake hash may be emitted for the single generic tarball entry.
    #[test]
    fn build_zig_zon_omits_hash_when_no_hash() {
        let mut platform_hashes = std::collections::BTreeMap::new();
        let url =
            "https://example.invalid/example-org/demo-client/releases/download/v1.4.0-rc.32/demo-client-zig-v1.4.0-rc.32.tar.gz"
                .to_string();
        platform_hashes.insert("generic".to_string(), (url, None));
        let content = render_build_zig_zon(
            "demo_client",
            "../../packages/zig",
            DependencyMode::Registry,
            "1.4.0-rc.32",
            &platform_hashes,
            false,
        );
        assert!(
            !content.contains(".hash"),
            "build.zig.zon must omit fake hash metadata when no hash is available, got:\n{content}"
        );
    }

    /// Regression test for the malformed asset URL bug: the rendered URL must
    /// include the repo segment (`<org>/<repo>/releases/...`).  Previously the
    /// codegen defaulted `github_repo` to `https://github.com/<org>` (no
    /// repo), producing `https://github.com/<org>/releases/...` which 404s.
    /// Now the URL is a single generic (no platform suffix) source tarball.
    #[test]
    fn build_zig_zon_emits_full_release_url_with_repo_segment_and_platform_suffix() {
        let mut platform_hashes = std::collections::BTreeMap::new();
        let url =
            "https://example.invalid/example-org/demo-markup/releases/download/v3.5.1/demo-markup-rs-zig-v3.5.1.tar.gz"
                .to_string();
        platform_hashes.insert("generic".to_string(), (url, None));
        let content = render_build_zig_zon(
            "demo_markup",
            "../../packages/zig",
            DependencyMode::Registry,
            "3.5.1",
            &platform_hashes,
            false,
        );
        // Verify the generic (no-suffix) URL is present with proper repo segment.
        let expected_url =
            "https://example.invalid/example-org/demo-markup/releases/download/v3.5.1/demo-markup-rs-zig-v3.5.1.tar.gz";
        assert!(
            content.contains(expected_url),
            "build.zig.zon must emit the generic source tarball URL with proper repo segment; got:\n{content}"
        );
    }
}

#[cfg(test)]
mod detect_stale_zig_hash_tests {
    use crate::core::config::e2e::DependencyMode;
    use crate::e2e::codegen::zig::build::render_build_zig_zon;

    use super::detect_stale_zig_hash;
    use super::supported_zig_platforms;

    /// Stale hash detection: hash contains rc.50, current version is rc.57 → true (stale).
    #[test]
    fn detects_stale_hash_with_older_rc_version() {
        let result = detect_stale_zig_hash(
            "demo_client-1.4.0-rc.50-Jfgk_HsxAQAl3_LX7NCs1l27EHcYVF9dieEDCVAwUxK9",
            "1.4.0-rc.57",
            "demo_client",
        );
        assert!(result, "expected stale hash detection (rc.50 vs rc.57), but got false");
    }

    /// Matching hash and version: hash contains rc.57, current version is rc.57 → false (fresh).
    #[test]
    fn accepts_matching_version_in_hash() {
        let result = detect_stale_zig_hash(
            "demo_client-1.4.0-rc.57-Jfgk_HsxAQAl3_LX7NCs1l27EHcYVF9dieEDCVAwUxK9",
            "1.4.0-rc.57",
            "demo_client",
        );
        assert!(!result, "expected fresh hash (rc.57 matches), but got true (stale)");
    }

    /// Matching stable version: hash contains 1.4.0, current version is 1.4.0 → false (fresh).
    #[test]
    fn accepts_matching_stable_version() {
        let result = detect_stale_zig_hash(
            "demo_client-1.4.0-Jfgk_HsxAQAl3_LX7NCs1l27EHcYVF9dieEDCVAwUxK9",
            "1.4.0",
            "demo_client",
        );
        assert!(
            !result,
            "expected fresh hash (1.4.0 matches stable), but got true (stale)"
        );
    }

    /// Malformed hash (wrong pkg_name prefix) → false (no prefix match, silent fail).
    #[test]
    fn returns_false_for_wrong_pkg_name_prefix() {
        let result = detect_stale_zig_hash(
            "wrong_pkg-1.4.0-rc.50-Jfgk_HsxAQAl3_LX7NCs1l27EHcYVF9dieEDCVAwUxK9",
            "1.4.0-rc.57",
            "demo_client",
        );
        assert!(
            !result,
            "expected no detection for mismatched pkg_name prefix, but got true"
        );
    }

    /// Regression test: zig platform URLs must use Rust target triples to match
    /// publish-zig action asset naming (e.g., aarch64-unknown-linux-gnu, not linux-aarch64).
    #[test]
    fn build_zig_zon_emits_rust_triple_platform_suffixes() {
        let mut platform_hashes = std::collections::BTreeMap::new();
        for platform in supported_zig_platforms() {
            let url = format!(
                "https://github.com/example/releases/download/v1.0.0/mylib-zig-v1.0.0-{}.tar.gz",
                platform
            );
            platform_hashes.insert(platform.to_string(), (url, None));
        }

        let content = render_build_zig_zon(
            "mylib",
            "../../packages/zig",
            DependencyMode::Registry,
            "1.0.0",
            &platform_hashes,
            false,
        );

        // Verify all Rust target triples are present in the emitted URLs
        assert!(
            content.contains("aarch64-unknown-linux-gnu"),
            "URL must include aarch64-unknown-linux-gnu triple: {content}"
        );
        assert!(
            content.contains("aarch64-apple-darwin"),
            "URL must include aarch64-apple-darwin triple: {content}"
        );
        assert!(
            content.contains("x86_64-unknown-linux-gnu"),
            "URL must include x86_64-unknown-linux-gnu triple: {content}"
        );
        assert!(
            content.contains("x86_64-apple-darwin"),
            "URL must include x86_64-apple-darwin triple: {content}"
        );
        assert!(
            content.contains("x86_64-pc-windows-msvc"),
            "URL must include x86_64-pc-windows-msvc triple: {content}"
        );

        // Verify old simple platform names are NOT present
        assert!(
            !content.contains("linux-x86_64"),
            "URL must NOT use simple platform name linux-x86_64: {content}"
        );
        assert!(
            !content.contains("linux-aarch64"),
            "URL must NOT use simple platform name linux-aarch64: {content}"
        );
        assert!(
            !content.contains("macos-arm64"),
            "URL must NOT use simple platform name macos-arm64: {content}"
        );
        assert!(
            !content.contains("macos-x86_64"),
            "URL must NOT use simple platform name macos-x86_64: {content}"
        );
        assert!(
            !content.contains("windows-x86_64"),
            "URL must NOT use simple platform name windows-x86_64: {content}"
        );
    }
}