tak-cli 0.0.2

Benchmark command-line programs and track their performance over time. Experimental; do not use.
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
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
//! Backfill history from published release binaries.
//!
//! A new adopter's first chart is empty, and an empty chart persuades nobody.
//! Rather than rebuilding a project at a hundred historical commits — hours of
//! compute, and impossible for anyone without a reproducible build — this
//! downloads the binaries the project already published and measures those.
//! Minutes, not hours, and it works for projects whose old commits no longer
//! build at all.
//!
//! Network and archive handling shell out to `curl` and `tar`/`unzip` for the
//! same reason [`crate::notes`] shells out to `git`: proxies, CA bundles and
//! credential helpers are already solved there, and the dependency tree stays
//! small.

use anyhow::{Context, Result, bail};
use asset_picker::{AssetPicker, Format, detect_platform_from_url};
use serde::Deserialize;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

#[derive(Debug, Clone, Deserialize)]
pub struct Asset {
    pub name: String,
    #[serde(rename = "browser_download_url")]
    pub url: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct Release {
    #[serde(rename = "tag_name")]
    pub tag: String,
    pub published_at: Option<String>,
    #[serde(default)]
    pub prerelease: bool,
    /// Drafts are visible to authenticated requests and are not published
    /// artefacts, so benchmarking one would record a version nobody can install.
    #[serde(default)]
    pub draft: bool,
    #[serde(default)]
    pub assets: Vec<Asset>,
}

/// GitHub's maximum, and the fewest round trips per page of history.
const PER_PAGE: usize = 100;
/// Bound the walk so a `--limit` larger than a project's history cannot spin.
const MAX_PAGES: usize = 10;

fn github_token() -> Option<String> {
    std::env::var("GITHUB_TOKEN")
        .or_else(|_| std::env::var("GH_TOKEN"))
        .ok()
        .filter(|t| !t.is_empty())
}

/// Run curl, keeping the bearer token off the command line.
///
/// The split matters. A token passed as `-H` lands in argv, where any local
/// process reading `/proc` recovers it, so it goes over stdin via `--config -`.
/// The URL and output path are *not* secret and go as ordinary arguments —
/// which also sidesteps curl's config quoting, where a value containing `"` or
/// a newline would otherwise close the quoted field and inject further
/// directives. A crafted `browser_download_url` could then add its own `url =`
/// line and receive the Authorization header meant for GitHub.
fn curl(url: &str, output: Option<&Path>, auth: bool) -> Result<Vec<u8>> {
    // `proto`/`proto-redir` are the load-bearing part: `location` follows
    // redirects, and without pinning the redirect protocol a downgrade to
    // http:// on the same host would carry the Authorization header in clear.
    // Checking only the initial URL is not enough.
    let mut cfg = String::from(
        "silent\nshow-error\nlocation\nfail\nproto = \"=https\"\nproto-redir = \"=https\"\n",
    );
    cfg.push_str("header = \"User-Agent: tak\"\n");
    if auth && let Some(token) = github_token() {
        // curl's config format has no escape for `"` inside a quoted value, and
        // GitHub tokens are ASCII-alphanumeric with `_`, so a token containing
        // a quote is malformed rather than something to escape.
        if token.contains(['"', '\n', '\r']) {
            bail!("refusing to use a token containing quotes or newlines");
        }
        cfg.push_str(&format!("header = \"Authorization: Bearer {token}\"\n"));
    }

    // Only ever talk to https endpoints: a `http://` or `file://` redirect
    // target would send the header in clear or read local files.
    if !url.starts_with("https://") {
        bail!("refusing a non-https URL: {url}");
    }

    let mut cmd = Command::new("curl");
    cmd.arg("--config").arg("-");
    if let Some(p) = output {
        cmd.arg("-o").arg(p);
    }
    cmd.arg("--").arg(url);

    let mut child = cmd
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .context("failed to run curl — is it installed?")?;
    child
        .stdin
        .take()
        .context("curl stdin unavailable")?
        .write_all(cfg.as_bytes())?;

    let out = child.wait_with_output()?;
    if !out.status.success() {
        bail!(
            "curl failed: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        );
    }
    Ok(out.stdout)
}

/// Releases for `repo` ("owner/name"), newest first, pre-releases excluded.
///
/// Pages until `limit` qualifying releases are found or the history runs out —
/// a first page consisting mostly of pre-releases would otherwise silently
/// return far fewer than asked for.
///
/// Uses the GitHub API directly rather than mise-versions: that index only
/// covers mise's curated registry, and backfill has to work for any project.
pub fn list_releases(repo: &str, limit: usize) -> Result<Vec<Release>> {
    let mut out: Vec<Release> = Vec::new();

    for page in 1..=MAX_PAGES {
        let url =
            format!("https://api.github.com/repos/{repo}/releases?per_page={PER_PAGE}&page={page}");
        let body = curl(&url, None, true)?;
        let batch: Vec<Release> =
            serde_json::from_slice(&body).context("could not parse the GitHub releases API")?;
        let exhausted = batch.len() < PER_PAGE;

        out.extend(
            batch
                .into_iter()
                .filter(|r| !r.prerelease && !r.draft && !r.assets.is_empty()),
        );

        if out.len() >= limit {
            break;
        }
        if exhausted {
            return Ok(finish(out, limit));
        }
        if page == MAX_PAGES {
            eprintln!(
                "warning: stopped after {MAX_PAGES} pages with {} of {limit} releases; \
                 older releases exist but were not fetched",
                out.len()
            );
        }
    }

    Ok(finish(out, limit))
}

fn finish(mut v: Vec<Release>, limit: usize) -> Vec<Release> {
    v.truncate(limit);
    v
}

/// Whether the picker's tables actually cover this host.
///
/// Kept in step with `asset_picker`'s own OS and arch tables; anything outside
/// them leaves its token list empty, which disables the corresponding filter
/// rather than rejecting.
fn host_is_recognised() -> bool {
    matches!(std::env::consts::OS, "linux" | "macos" | "windows")
        && matches!(std::env::consts::ARCH, "x86_64" | "aarch64")
}

/// The libc to select assets for.
///
/// Passing `None` makes the picker assume gnu on Linux, which is right for the
/// common case and wrong on Alpine, where a gnu binary simply will not start.
/// A musl-built `tak` is a good proxy for a musl host, and it is the only
/// signal available without shelling out.
///
/// This matters beyond whether the binary runs: musl and glibc builds have
/// different allocators and different startup costs, so picking the wrong one
/// measures something the project's users never execute.
fn host_libc() -> Option<String> {
    cfg!(target_env = "musl").then(|| "musl".to_string())
}

/// Can this asset actually serve as a benchmark subject?
///
/// The picker answers "which asset fits this platform", which is not the same
/// question. It will happily choose a source tarball when that is the only
/// candidate, or a `.7z` this code cannot unpack — and an unusable pick means a
/// skipped release rather than a fallback to the next-best asset. Filtering
/// first lets the picker choose the best of what remains.
fn is_usable_subject(name: &str) -> bool {
    let n = name.to_lowercase();

    // A .vsix is a zip, so `Format` classifies it as openable, but it is a VS
    // Code extension package — never a CLI binary. Admitting it wastes a
    // download and then skips the release with a confusing "no `tool` inside".
    if n.ends_with(".vsix") {
        return false;
    }

    // Checksums and signatures describe a release rather than being one. The
    // picker scores them positively — verified: given only
    // `tool-x86_64-unknown-linux-gnu.tar.gz.sha256` it returns exactly that —
    // because mise consumes sidecars deliberately elsewhere. Here they are
    // never a subject.
    const SIDECAR: [&str; 9] = [
        ".sha256",
        ".sha512",
        ".sha1",
        ".md5",
        ".asc",
        ".sig",
        ".pem",
        ".sbom",
        ".intoto.jsonl",
    ];
    if SIDECAR.iter().any(|e| n.ends_with(e)) {
        return false;
    }

    // Source drops contain no executable. Whole-word so `resource-cli` survives.
    let stem = n.rsplit('/').next().unwrap_or(&n);
    if ["source", "sources", "src"].iter().any(|t| {
        stem.split(|c: char| !c.is_ascii_alphanumeric())
            .any(|w| w == *t)
    }) {
        return false;
    }

    // Only formats `fetch_binary` can actually open. Both sides ask `Format`
    // rather than matching suffixes independently, so a name like `.vsix` —
    // which is a zip — cannot pass the filter and then be rejected at unpack.
    match Format::from_file_name(&n) {
        // `Format::Raw` is a catch-all for "no recognised archive suffix",
        // which also covers `tool-linux-x86_64.json`, `.yaml`, `.run` and
        // anything else a project ships alongside its binaries. Those score on
        // their platform tokens, and marking one executable and running it
        // benchmarks the wrong program. A real bare binary has no extension at
        // all, or `.exe`.
        Format::Raw => {
            let base = n.rsplit('/').next().unwrap_or(&n);
            base.ends_with(".exe") || !has_file_extension(base)
        }
        // `tar` handles gz/xz/bz2/zst everywhere, but brotli and lz4 depend on
        // the host build. Admitting one only to fail at extraction turns a
        // usable release into a skipped one; excluding them lets the picker
        // choose a different asset instead.
        Format::Tar => !TAR_NEEDS_RARE_CODEC.iter().any(|e| n.ends_with(e)),
        Format::Zip => true,
        _ => false,
    }
}

/// Does this filename end in something that looks like a file extension?
///
/// "Contains a dot" is not the same question. A raw unix binary is routinely
/// published as `tool-v1.2.3-linux-x86_64`, and treating the version's dots as
/// an extension rejects a perfectly good subject. An extension is short, purely
/// alphanumeric, and not just digits — `json` and `yaml` qualify, the `3` at
/// the end of a version does not.
fn has_file_extension(base: &str) -> bool {
    match base.rsplit_once('.') {
        None => false,
        Some((_, ext)) => {
            !ext.is_empty()
                && ext.len() <= 6
                && ext.chars().all(|c| c.is_ascii_alphanumeric())
                && !ext.chars().all(|c| c.is_ascii_digit())
        }
    }
}

/// Tar compressions that `tar` frequently cannot decompress unaided.
const TAR_NEEDS_RARE_CODEC: [&str; 4] = [".tar.br", ".tbr", ".tar.lz4", ".tlz4"];

pub fn pick_asset(assets: &[Asset]) -> Option<&Asset> {
    // The picker's platform tables cover the mainstream targets and skip the
    // OS or arch filter entirely when it does not recognise one — which on
    // FreeBSD or riscv64 would let a linux-x86_64 tarball match a machine that
    // cannot run it. Refusing to guess is the right answer for a tool whose
    // whole purpose is trustworthy numbers.
    if !host_is_recognised() {
        return None;
    }

    let usable: Vec<String> = assets
        .iter()
        .map(|a| a.name.clone())
        .filter(|n| is_usable_subject(n))
        .collect();

    // If any candidate states a platform, the ones that do not are not
    // trustworthy. Verified against the picker: a release shipping
    // `tool-x86_64-unknown-linux-gnu.tar.gz` alongside `tool.tar.gz` hands the
    // latter to a macOS host, because the linux asset is filtered out and the
    // unlabelled one has nothing to filter on. In a release that publishes
    // per-platform builds, an unlabelled archive is some other artefact — not a
    // universal binary — so measuring it is measuring the wrong thing.
    //
    // A genuinely universal release, where nothing declares a platform, is
    // untouched by this.
    let declares_platform = |n: &String| detect_platform_from_url(n).is_some();
    let names: Vec<String> = if usable.iter().any(declares_platform) {
        usable
            .into_iter()
            .filter(|n| declares_platform(n))
            .collect()
    } else {
        usable
    };
    let picked = AssetPicker::with_libc(
        std::env::consts::OS.to_string(),
        std::env::consts::ARCH.to_string(),
        host_libc(),
    )
    // A macOS `.app` bundle is not something we can invoke as a subject.
    .with_no_app(true)
    .pick_best_asset(&names)?;

    assets.iter().find(|a| a.name == picked)
}

fn run(cmd: &mut Command, what: &str) -> Result<()> {
    let out = cmd
        .output()
        .with_context(|| format!("failed to run {what}"))?;
    if !out.status.success() {
        bail!(
            "{what} failed: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        );
    }
    Ok(())
}

/// Download `asset` into `dir`, unpack it, and return the path to `bin_name`.
///
/// Handles tarballs, zips and bare executables — the three shapes essentially
/// every CLI ships. The download is authenticated for the same reason the
/// listing is: a private repository's assets are not publicly readable, and
/// failing here after listing succeeded would skip every release.
pub fn fetch_binary(asset: &Asset, bin_name: &str, dir: &Path) -> Result<PathBuf> {
    // Start clean. Reusing a populated directory risks `find_binary` picking up
    // an executable left by an earlier release and attributing its measurement
    // to the wrong tag.
    std::fs::remove_dir_all(dir).ok();
    std::fs::create_dir_all(dir)?;
    // Asset names come from the API and are not trusted: `../../x` would escape
    // the per-release directory and write wherever it liked.
    let archive = dir.join(safe_component(&asset.name)?);

    curl(&asset.url, Some(&archive), true)?;

    // Same classifier the candidate filter used, so the two can never disagree.
    // Matching suffixes independently in each place is what let `.vsix` pass the
    // filter as a zip and then be rejected here as unknown.
    match Format::from_file_name(&asset.name) {
        // `tar -xf` sniffs the compression, so gz/xz/bz2/zst all work here.
        Format::Tar => run(
            Command::new("tar").args([
                "-xf",
                &archive.to_string_lossy(),
                "-C",
                &dir.to_string_lossy(),
            ]),
            "tar",
        )?,
        Format::Zip => run(
            Command::new("unzip").args([
                "-oq",
                &archive.to_string_lossy(),
                "-d",
                &dir.to_string_lossy(),
            ]),
            "unzip",
        )?,
        // The download is the artefact.
        Format::Raw => {
            make_executable(&archive)?;
            return Ok(archive);
        }
        // Unreachable via `pick_asset`, which filters these out, but a caller
        // could hand one over directly. Skipping loudly beats executing a
        // tarball as if it were a program, which is what the old suffix chain
        // did for anything it did not recognise.
        other => bail!("cannot unpack {} ({other:?})", asset.name),
    }

    let found = find_binary(dir, bin_name)
        .with_context(|| format!("no `{bin_name}` inside {}", asset.name))?;
    make_executable(&found)?;
    Ok(found)
}

/// Reduce an untrusted name to a single safe path component.
///
/// `Path::join` with a value containing `..` or separators escapes the intended
/// directory, and both asset names and tags come from the GitHub API.
fn safe_component(name: &str) -> Result<String> {
    let base = Path::new(name)
        .file_name()
        .and_then(|s| s.to_str())
        .filter(|s| !s.is_empty() && *s != "." && *s != "..")
        .with_context(|| format!("unusable name from the API: {name:?}"))?;
    Ok(base.to_string())
}

/// A filesystem-safe, collision-free directory name for a release.
///
/// Sanitising alone is not enough: tags may contain `/` (`release/1.0`), and
/// mapping both `v1/0` and `v1_0` to `v1_0` would let two releases share a
/// directory. The index keeps them distinct.
pub fn release_dir_name(index: usize, tag: &str) -> String {
    format!("{index:04}-{}", safe_dir_name(tag))
}

/// A filesystem-safe directory name for a release tag.
///
/// Tags may contain `/` (`release/1.0`) and are otherwise arbitrary, so anything
/// outside a conservative set becomes `_`.
fn safe_dir_name(tag: &str) -> String {
    let s: String = tag
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
                c
            } else {
                '_'
            }
        })
        .collect();
    let trimmed = s.trim_matches('.');
    if trimmed.is_empty() {
        "release".to_string()
    } else {
        trimmed.to_string()
    }
}

fn make_executable(p: &Path) -> Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = std::fs::metadata(p)?.permissions();
        perms.set_mode(0o755);
        std::fs::set_permissions(p, perms)?;
    }
    #[cfg(not(unix))]
    let _ = p;
    Ok(())
}

/// Names to accept for the executable.
///
/// Windows archives ship `tool.exe`, while `--bin` defaults to the repository
/// name without a suffix, so an exact-match-only search skips every Windows
/// release.
fn binary_candidates(name: &str) -> Vec<String> {
    let mut v = vec![name.to_string()];
    if cfg!(windows) && !name.ends_with(".exe") {
        v.push(format!("{name}.exe"));
    }
    v
}

/// Depth-limited search for the executable. Release archives nest one or two
/// levels at most; a full walk risks wandering into a vendored tree.
fn find_binary(dir: &Path, name: &str) -> Option<PathBuf> {
    fn walk(dir: &Path, names: &[String], depth: u32) -> Option<PathBuf> {
        if depth > 3 {
            return None;
        }
        let entries = std::fs::read_dir(dir).ok()?;
        let mut dirs = Vec::new();
        for e in entries.flatten() {
            let p = e.path();
            if p.is_dir() {
                dirs.push(p);
            } else if p
                .file_name()
                .and_then(|s| s.to_str())
                .is_some_and(|f| names.iter().any(|n| n == f))
            {
                return Some(p);
            }
        }
        dirs.into_iter().find_map(|d| walk(&d, names, depth + 1))
    }
    walk(dir, &binary_candidates(name), 0)
}

/// Whether the current directory is inside a git work tree.
///
/// Distinguishes "not in a repository" from "tag not fetched", which otherwise
/// both surface as a missing tag and send people looking in the wrong place.
pub fn in_git_repo() -> bool {
    Command::new("git")
        .args(["rev-parse", "--is-inside-work-tree"])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

/// Resolve a release tag to the commit it points at, if the tag is available
/// locally. Returns `None` rather than failing — a shallow clone legitimately
/// has no tags, and the caller can still report that precisely.
pub fn tag_commit(tag: &str) -> Option<String> {
    let out = Command::new("git")
        .args(["rev-parse", &format!("{tag}^{{commit}}")])
        .output()
        .ok()?;
    out.status
        .success()
        .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
}

/// Strip a leading `v` for display. Deliberately does no other normalisation —
/// tool version strings are frequently not semver and must stay opaque.
pub fn version_of(tag: &str) -> &str {
    tag.strip_prefix('v').unwrap_or(tag)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pick_asset_chooses_the_best_candidate() {
        let assets: Vec<Asset> = [
            "tool-x86_64-apple-darwin.tar.gz",
            "tool-x86_64-unknown-linux-gnu.tar.gz",
            "tool-x86_64-unknown-linux-musl.tar.gz",
            "tool-x86_64-unknown-linux-musl.tar.gz.sha256",
        ]
        .iter()
        .map(|n| Asset {
            name: n.to_string(),
            url: format!("https://example.invalid/{n}"),
        })
        .collect();

        // Only meaningful on the platform this asserts against.
        if std::env::consts::OS == "linux" && std::env::consts::ARCH == "x86_64" {
            let got = pick_asset(&assets).expect("something should match");
            // gnu, not musl, on a glibc host. The two builds differ in
            // allocator and startup cost, so the one users actually run is the
            // one worth measuring — and it is never the darwin asset or the
            // checksum file.
            let want = if cfg!(target_env = "musl") {
                "tool-x86_64-unknown-linux-musl.tar.gz"
            } else {
                "tool-x86_64-unknown-linux-gnu.tar.gz"
            };
            assert_eq!(got.name, want);
        }
    }

    #[test]
    fn version_strips_only_the_v_prefix() {
        assert_eq!(version_of("v1.2.3"), "1.2.3");
        assert_eq!(version_of("2024.01.15"), "2024.01.15");
        // Non-semver tags must survive untouched.
        assert_eq!(version_of("nightly"), "nightly");
        assert_eq!(version_of("lts-iron"), "lts-iron");
    }

    #[test]
    fn windows_archives_may_carry_an_exe_suffix() {
        let c = binary_candidates("mycli");
        assert!(c.contains(&"mycli".to_string()));
        if cfg!(windows) {
            assert!(c.contains(&"mycli.exe".to_string()));
        }
        // An explicit .exe must not become mycli.exe.exe.
        assert_eq!(binary_candidates("mycli.exe").len(), 1);
    }

    #[test]
    fn source_archives_are_not_benchmark_subjects() {
        // The picker selects these when they are the only candidate; they
        // contain no executable.
        assert!(!is_usable_subject("tool-1.0-source.tar.gz"));
        assert!(!is_usable_subject("tool-src.tar.gz"));
        assert!(!is_usable_subject("sources.zip"));
        // Whole-word, so a tool whose name merely contains "src" survives.
        assert!(is_usable_subject("resource-cli-linux-x86_64.tar.gz"));
        assert!(is_usable_subject("srcery-linux-amd64.tar.gz"));
    }

    #[test]
    fn sidecars_are_never_subjects() {
        // Verified against the real picker: given only the .sha256 it returns
        // exactly that, so the filter has to catch them.
        for n in [
            "tool-x86_64-unknown-linux-gnu.tar.gz.sha256",
            "tool-x86_64-unknown-linux-gnu.tar.gz.asc",
            "tool.sig",
            "tool.intoto.jsonl",
        ] {
            assert!(!is_usable_subject(n), "{n} should be rejected");
        }
    }

    /// A .vsix unpacks fine — it is a zip — but never contains a CLI binary,
    /// so admitting it costs a download and a confusing skip.
    #[test]
    fn vsix_is_openable_but_never_a_subject() {
        assert_eq!(Format::from_file_name("tool.vsix"), Format::Zip);
        assert!(!is_usable_subject("tool-linux-x86_64.vsix"));
    }

    /// `Format::Raw` means "no recognised archive suffix", which is not the
    /// same as "an executable".
    #[test]
    fn platform_tagged_metadata_is_not_a_subject() {
        for n in [
            "tool-linux-x86_64.json",
            "tool-linux-x86_64.yaml",
            "tool-linux-x86_64.run",
            "tool-linux-x86_64.txt",
        ] {
            assert!(!is_usable_subject(n), "{n} should be rejected");
        }
        // A genuine bare binary still qualifies.
        assert!(is_usable_subject("tool-linux-x86_64"));
        assert!(is_usable_subject("tool.exe"));
    }

    /// A version's dots are not a file extension, and rejecting on "contains a
    /// dot" threw away raw binaries that projects really do publish.
    #[test]
    fn a_versioned_bare_binary_is_still_a_subject() {
        assert!(is_usable_subject("tool-v1.2.3-linux-x86_64"));
        assert!(is_usable_subject("tool-1.2.3"));
        assert!(is_usable_subject("tool-linux-amd64-2024.01.15"));
    }

    #[test]
    fn extension_detection_distinguishes_versions_from_suffixes() {
        assert!(has_file_extension("tool.json"));
        assert!(has_file_extension("tool.yaml"));
        assert!(has_file_extension("tool.exe"));
        // Trailing version components, not extensions.
        assert!(!has_file_extension("tool-1.2.3"));
        assert!(!has_file_extension("tool-v1.2.3-linux-x86_64"));
        assert!(!has_file_extension("tool"));
    }

    #[test]
    fn tar_codecs_the_host_may_lack_are_skipped() {
        assert!(is_usable_subject("tool-linux-x86_64.tar.gz"));
        assert!(is_usable_subject("tool-linux-x86_64.tar.zst"));
        // Better to let the picker choose another asset than to fail at
        // extraction and skip the release entirely.
        assert!(!is_usable_subject("tool-linux-x86_64.tar.br"));
        assert!(!is_usable_subject("tool-linux-x86_64.tar.lz4"));
    }

    #[test]
    fn only_openable_formats_are_subjects() {
        assert!(is_usable_subject("tool-linux-x86_64.tar.gz"));
        assert!(is_usable_subject("tool-linux-x86_64.zip"));
        assert!(is_usable_subject("tool"));
        assert!(is_usable_subject("tool.exe"));
        // fetch_binary cannot open these, and picking one skips the release.
        assert!(!is_usable_subject("tool-linux-x86_64.7z"));
        assert!(!is_usable_subject("tool-linux-x86_64.gz"));
        assert!(!is_usable_subject("tool.rar"));
    }

    #[test]
    fn a_platform_asset_beats_a_source_drop() {
        let assets: Vec<Asset> = [
            "tool-1.0-source.tar.gz",
            "tool-x86_64-unknown-linux-gnu.tar.gz",
        ]
        .iter()
        .map(|n| Asset {
            name: n.to_string(),
            url: format!("https://example.invalid/{n}"),
        })
        .collect();
        if std::env::consts::OS == "linux" && std::env::consts::ARCH == "x86_64" {
            assert_eq!(
                pick_asset(&assets).map(|a| a.name.as_str()),
                Some("tool-x86_64-unknown-linux-gnu.tar.gz")
            );
        }
    }

    /// The picker disables a filter it cannot populate, so an unrecognised host
    /// must be refused before it ever gets there.
    #[test]
    fn unrecognised_hosts_are_refused() {
        let recognised = matches!(std::env::consts::OS, "linux" | "macos" | "windows")
            && matches!(std::env::consts::ARCH, "x86_64" | "aarch64");
        assert_eq!(host_is_recognised(), recognised);

        if !host_is_recognised() {
            let assets = vec![Asset {
                name: "tool-x86_64-unknown-linux-gnu.tar.gz".to_string(),
                url: "https://example.invalid/x".to_string(),
            }];
            assert!(pick_asset(&assets).is_none());
        }
    }

    /// The case that motivated the platform-evidence rule: a release with
    /// per-platform builds, none of them ours, plus an unlabelled archive.
    #[test]
    fn an_unlabelled_archive_never_substitutes_for_a_platform_build() {
        let assets: Vec<Asset> = ["tool-x86_64-unknown-linux-gnu.tar.gz", "tool.tar.gz"]
            .iter()
            .map(|n| Asset {
                name: n.to_string(),
                url: format!("https://example.invalid/{n}"),
            })
            .collect();

        // On linux/x86_64 the labelled asset is the right answer.
        if std::env::consts::OS == "linux" && std::env::consts::ARCH == "x86_64" {
            assert_eq!(
                pick_asset(&assets).map(|a| a.name.as_str()),
                Some("tool-x86_64-unknown-linux-gnu.tar.gz")
            );
        }
        // On macOS the linux asset does not apply, and falling back to
        // `tool.tar.gz` would benchmark whatever that happens to be.
        if std::env::consts::OS == "macos" {
            assert!(pick_asset(&assets).is_none());
        }
    }

    /// A release where nothing declares a platform is still usable — that is a
    /// real and common shape, and rejecting it would exclude those projects.
    #[test]
    fn a_wholly_unlabelled_release_is_still_usable() {
        let assets = vec![Asset {
            name: "tool.tar.gz".to_string(),
            url: "https://example.invalid/tool.tar.gz".to_string(),
        }];
        assert!(pick_asset(&assets).is_some());
    }

    #[test]
    fn a_release_of_only_source_yields_nothing() {
        let assets = vec![Asset {
            name: "tool-1.0-source.tar.gz".to_string(),
            url: "https://example.invalid/x".to_string(),
        }];
        assert!(pick_asset(&assets).is_none());
    }

    #[test]
    fn release_dirs_never_collide() {
        // Sanitising alone maps both of these to `v1_0`.
        assert_ne!(
            release_dir_name(0, "v1/0"),
            release_dir_name(1, "v1_0"),
            "distinct releases must not share a work directory"
        );
        assert_eq!(release_dir_name(7, "v1.2.3"), "0007-v1.2.3");
        // Path separators must not survive into the name.
        assert!(!release_dir_name(0, "release/1.0").contains('/'));
        // A tag of only punctuation still yields something usable.
        assert!(!release_dir_name(0, "...").is_empty());
    }

    #[test]
    fn finds_a_nested_binary() {
        let dir = std::env::temp_dir().join(format!("tak-find-{}", std::process::id()));
        let nested = dir.join("tool-1.0").join("bin");
        std::fs::create_dir_all(&nested).unwrap();
        std::fs::write(nested.join("mycli"), b"#!/bin/sh\n").unwrap();

        assert_eq!(find_binary(&dir, "mycli"), Some(nested.join("mycli")));
        assert_eq!(find_binary(&dir, "absent"), None);

        std::fs::remove_dir_all(&dir).ok();
    }
}