Skip to main content

anodizer_core/
binstall.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3
4use anyhow::{Context as _, Result};
5
6use crate::archive_name::{binstall_pkg_fmt, render_archive_asset_name_with_variant};
7use crate::config::{ArchiveConfig, ArchivesConfig, BinstallConfig, BinstallOverride, CrateConfig};
8use crate::context::Context;
9use crate::target::map_target;
10
11/// Sentinel substituted into the rendered tag + asset name in place of the
12/// release version, then swapped for cargo-binstall's own `{ version }` token.
13/// Picked to be vanishingly unlikely to appear in a real project/version string
14/// so the post-render substitution is unambiguous.
15const VERSION_SENTINEL: &str = "__ANODIZE_BINSTALL_VERSION__";
16
17/// Generate or update `[package.metadata.binstall]` in a crate's Cargo.toml
18/// based on the provided BinstallConfig.  The `pkg_url` field is rendered
19/// through the template engine so that variables like `{{ .Version }}` and
20/// `{{ .Target }}` are expanded.
21///
22/// # Auto-derivation
23///
24/// When `binstall.enabled` is set and the user supplied **neither** a top-level
25/// `pkg_url` **nor** any `overrides`, anodize auto-derives a per-target
26/// `overrides.<rust-triple>` for every configured build target. Each derived
27/// override's `pkg_url` is the full GitHub release download URL for that
28/// target's archive — its asset name rendered through the *same*
29/// `archive.name_template` the archive stage uses, with the version positions
30/// expressed as cargo-binstall's `{ version }` token so the URL resolves for
31/// whatever version is being installed. Because the asset name is derived from
32/// the single source of truth ([`crate::archive_name`]), a derived URL
33/// can never drift from the asset the release actually uploads (the "binstall
34/// 404" class is eliminated by construction).
35///
36/// A user-supplied `pkg_url` or any `overrides` entry suppresses
37/// auto-derivation entirely — manual values always win.
38///
39/// The update is performed in place: anodize re-writes only the keys it owns
40/// (`pkg-url`, `bin-dir`, `pkg-fmt`, and the `overrides` sub-table). Any other
41/// key a user added by hand — cargo-binstall's `disabled-strategies`, the
42/// `[package.metadata.binstall.signing]` sub-table, or features anodize does
43/// not yet model — is preserved verbatim. An owned key that is now unset in
44/// config is cleared, but unknown keys still survive.
45pub fn generate_binstall_metadata(
46    crate_cfg: &CrateConfig,
47    config: &BinstallConfig,
48    default_targets: &[String],
49    ctx: &mut Context,
50    dry_run: bool,
51) -> Result<()> {
52    let cargo_toml_path = Path::new(&crate_cfg.path).join("Cargo.toml");
53    let content = std::fs::read_to_string(&cargo_toml_path)
54        .with_context(|| format!("failed to read {}", cargo_toml_path.display()))?;
55
56    let mut doc = content
57        .parse::<toml_edit::DocumentMut>()
58        .with_context(|| format!("failed to parse {}", cargo_toml_path.display()))?;
59
60    // Render the anodize-owned values up front so a template error aborts
61    // before any mutation (and before the dry-run short-circuit reports
62    // success on a config that would have failed).
63    let rendered_pkg_url =
64        match config.pkg_url {
65            Some(ref pkg_url) => Some(ctx.render_template(pkg_url).with_context(|| {
66                format!("failed to render binstall pkg_url template: {}", pkg_url)
67            })?),
68            None => None,
69        };
70
71    // Precedence: a user-supplied top-level `pkg_url` or any explicit
72    // `overrides` entry takes full manual control. Auto-derivation engages only
73    // when the user supplied NEITHER — the common case where anodize already
74    // knows every fact (owner/repo, tag template, per-target asset names) the
75    // metadata needs.
76    let user_supplied_overrides = config.overrides.as_ref().is_some_and(|o| !o.is_empty());
77    let rendered_overrides = if config.pkg_url.is_none() && !user_supplied_overrides {
78        let derived = derive_overrides(crate_cfg, default_targets, ctx)?;
79        match derived {
80            Some(map) => render_overrides_map(&map, ctx)?,
81            None => None,
82        }
83    } else {
84        render_overrides(config, ctx)?
85    };
86
87    let log = ctx.logger("build");
88    if dry_run {
89        log.status(&format!(
90            "(dry-run) would update [package.metadata.binstall] in {}",
91            cargo_toml_path.display()
92        ));
93        return Ok(());
94    }
95
96    // Ensure [package.metadata] exists
97    let package = doc
98        .get_mut("package")
99        .and_then(|p| p.as_table_mut())
100        .with_context(|| format!("no [package] table in {}", cargo_toml_path.display()))?;
101
102    if !package.contains_key("metadata") {
103        package.insert("metadata", toml_edit::Item::Table(toml_edit::Table::new()));
104    }
105
106    let metadata = package
107        .get_mut("metadata")
108        .and_then(|m| m.as_table_mut())
109        .with_context(|| {
110            format!(
111                "[package].metadata is not a table in {}",
112                cargo_toml_path.display()
113            )
114        })?;
115
116    normalize_binstall_to_table(metadata).with_context(|| {
117        format!(
118            "[package.metadata.binstall] is neither a table nor an inline table in {}",
119            cargo_toml_path.display()
120        )
121    })?;
122
123    // Merge in place: mutate the existing table so unknown keys
124    // (disabled-strategies, signing, future unknowns) survive.
125    let binstall = metadata
126        .get_mut("binstall")
127        .and_then(|b| b.as_table_mut())
128        .with_context(|| {
129            format!(
130                "[package.metadata.binstall] is not a table in {}",
131                cargo_toml_path.display()
132            )
133        })?;
134
135    set_or_remove_str(binstall, "pkg-url", rendered_pkg_url.as_deref());
136    set_or_remove_str(binstall, "bin-dir", config.bin_dir.as_deref());
137    set_or_remove_str(binstall, "pkg-fmt", config.pkg_fmt.as_deref());
138
139    match rendered_overrides {
140        Some(overrides_table) => {
141            binstall.insert("overrides", toml_edit::Item::Table(overrides_table));
142        }
143        None => {
144            binstall.remove("overrides");
145        }
146    }
147
148    std::fs::write(&cargo_toml_path, doc.to_string())
149        .with_context(|| format!("failed to write {}", cargo_toml_path.display()))?;
150
151    log.status(&format!(
152        "updated [package.metadata.binstall] in {}",
153        cargo_toml_path.display()
154    ));
155
156    Ok(())
157}
158
159/// Ensure `metadata.binstall` is a header table so the in-place merge can
160/// mutate it. Three shapes are handled:
161///
162/// - **missing** — insert an empty header table.
163/// - **header table** — already correct; left untouched.
164/// - **inline table** (`binstall = { pkg-url = "…" }`) — converted to a header
165///   table, preserving every key/value (including user-authored ones anodize
166///   does not model) so an inline-metadata user isn't hard-blocked.
167///
168/// Returns an error only when `binstall` is present but is neither a table nor
169/// an inline table (e.g. a scalar/array), which is a malformed manifest.
170fn normalize_binstall_to_table(metadata: &mut toml_edit::Table) -> Result<()> {
171    match metadata.get("binstall") {
172        None => {
173            metadata.insert("binstall", toml_edit::Item::Table(toml_edit::Table::new()));
174            Ok(())
175        }
176        Some(item) if item.is_table() => Ok(()),
177        Some(item) => {
178            let inline = item.as_inline_table().with_context(|| {
179                "[package.metadata.binstall] is neither a table nor an inline table".to_string()
180            })?;
181            // Rebuild as a header table, carrying every existing key/value
182            // (anodize-owned and unknown alike) so nothing is dropped.
183            let mut table = toml_edit::Table::new();
184            for (k, v) in inline.iter() {
185                table.insert(k, toml_edit::Item::Value(v.clone()));
186            }
187            metadata.insert("binstall", toml_edit::Item::Table(table));
188            Ok(())
189        }
190    }
191}
192
193/// Set `key` to `value` when present, or remove it when `None`. Removing a
194/// now-unset anodize-owned key keeps the merge faithful to config while
195/// leaving sibling unknown keys intact.
196fn set_or_remove_str(table: &mut toml_edit::Table, key: &str, value: Option<&str>) {
197    match value {
198        Some(v) => {
199            table.insert(key, toml_edit::value(v));
200        }
201        None => {
202            table.remove(key);
203        }
204    }
205}
206
207/// Render the per-target `overrides` sub-table from config, or `None` when no
208/// overrides are configured. Override `pkg_url` templates are rendered through
209/// the context so anodize tokens expand while cargo-binstall's own `{ ... }`
210/// tokens survive intact.
211fn render_overrides(config: &BinstallConfig, ctx: &Context) -> Result<Option<toml_edit::Table>> {
212    let Some(ref overrides) = config.overrides else {
213        return Ok(None);
214    };
215    if overrides.is_empty() {
216        return Ok(None);
217    }
218    render_overrides_map(overrides, ctx)
219}
220
221/// Render a `<triple> -> BinstallOverride` map into a TOML `overrides` table.
222/// Shared by the user-supplied path ([`render_overrides`]) and the
223/// auto-derived path so both emit identical `[…overrides.<triple>]` headers.
224/// `pkg_url` values are rendered through the context so any anodize tokens
225/// expand while cargo-binstall's own `{ ... }` tokens survive intact.
226fn render_overrides_map(
227    overrides: &BTreeMap<String, BinstallOverride>,
228    ctx: &Context,
229) -> Result<Option<toml_edit::Table>> {
230    if overrides.is_empty() {
231        return Ok(None);
232    }
233    let mut overrides_table = toml_edit::Table::new();
234    // Render as proper [package.metadata.binstall.overrides.<triple>]
235    // headers rather than an inline dotted key.
236    overrides_table.set_implicit(true);
237    // BTreeMap iteration is sorted, so emission order is deterministic.
238    for (triple, ovr) in overrides {
239        let mut entry = toml_edit::Table::new();
240        if let Some(ref pkg_url) = ovr.pkg_url {
241            let rendered = ctx.render_template(pkg_url).with_context(|| {
242                format!("failed to render binstall overrides.{triple} pkg_url template: {pkg_url}")
243            })?;
244            entry.insert("pkg-url", toml_edit::value(rendered));
245        }
246        if let Some(ref pkg_fmt) = ovr.pkg_fmt {
247            entry.insert("pkg-fmt", toml_edit::value(pkg_fmt.as_str()));
248        }
249        if let Some(ref bin_dir) = ovr.bin_dir {
250            entry.insert("bin-dir", toml_edit::value(bin_dir.as_str()));
251        }
252        overrides_table.insert(triple, toml_edit::Item::Table(entry));
253    }
254    Ok(Some(overrides_table))
255}
256
257// ---------------------------------------------------------------------------
258// Auto-derivation
259// ---------------------------------------------------------------------------
260
261/// Auto-derive a per-target `overrides` map for `crate_cfg` when binstall is
262/// enabled but the user supplied no `pkg_url`/`overrides`.
263///
264/// For every configured build target, the override's `pkg_url` is the full
265/// GitHub release download URL for that target's archive, with the version
266/// positions expressed as cargo-binstall's `{ version }` token. The asset name
267/// is rendered through the *same* `archive.name_template` the archive stage
268/// uses (via [`crate::archive_name`]), so the URL is byte-identical to
269/// the asset the release uploads — no drift, no 404.
270///
271/// Returns `None` (no derivation) when the crate has no release repo, no
272/// binstallable archive entry, or no resolvable targets; the surrounding
273/// `binstall.enabled` block then emits whatever the user explicitly set
274/// (possibly nothing), preserving the manual escape hatch.
275fn derive_overrides(
276    crate_cfg: &CrateConfig,
277    default_targets: &[String],
278    ctx: &mut Context,
279) -> Result<Option<BTreeMap<String, BinstallOverride>>> {
280    let Some((owner, repo, download_base)) = release_repo(crate_cfg, ctx) else {
281        return Ok(None);
282    };
283
284    // Per-target asset filenames rendered with the sentinel stamped as the
285    // version, so each name's version positions can be swapped for
286    // cargo-binstall's `{ version }` token below. Shared with the `curl | sh`
287    // installer's case table via [`crate_archive_asset_names`], so a binstall
288    // `pkg_url` and an installer URL for the same target can never disagree.
289    let prior = stamp_sentinel_version(ctx);
290    let assets = crate_archive_asset_names(crate_cfg, default_targets, ctx);
291    restore_version(ctx, prior);
292    let Some(assets) = assets? else {
293        return Ok(None);
294    };
295
296    // The tag the release uploads under, with the version expressed as the
297    // cargo-binstall `{ version }` token. Rendered once (target-independent) by
298    // stamping the sentinel as the version, then swapping it for `{ version }`.
299    let tag_with_token = render_tag_with_version_token(crate_cfg, ctx)?;
300
301    let mut map: BTreeMap<String, BinstallOverride> = BTreeMap::new();
302    for (target, asset) in &assets {
303        let Some(pkg_fmt) = binstall_pkg_fmt(&asset.format) else {
304            // A format cargo-binstall cannot binstall (binary / none): no usable
305            // override for this target. Skip it rather than emit an unresolvable
306            // pkg_fmt.
307            continue;
308        };
309        let asset_with_token = asset.asset_name.replace(VERSION_SENTINEL, "{ version }");
310        let pkg_url = format!(
311            "{download_base}/{owner}/{repo}/releases/download/{tag_with_token}/{asset_with_token}"
312        );
313        map.insert(
314            target.clone(),
315            BinstallOverride {
316                pkg_url: Some(pkg_url),
317                pkg_fmt: Some(pkg_fmt.to_string()),
318                bin_dir: None,
319            },
320        );
321    }
322
323    if map.is_empty() {
324        return Ok(None);
325    }
326    Ok(Some(map))
327}
328
329/// One released archive asset: the configured archive `format` string and the
330/// byte-exact filename the archive stage uploads for a single build target.
331#[derive(Debug, Clone, PartialEq, Eq)]
332pub struct ArchiveAssetName {
333    /// The archive format string (`tar.gz`, `zip`, …) — its file extension.
334    pub format: String,
335    /// The full asset filename (`name_template` stem + format extension),
336    /// byte-identical to what the archive stage writes.
337    pub asset_name: String,
338}
339
340/// Render the byte-exact archive asset filename the archive stage will upload
341/// for every released target of `crate_cfg`'s primary (binstallable) archive,
342/// keyed by target triple, using the version/project template vars currently
343/// set on `ctx`.
344///
345/// This is the single source of truth shared by cargo-binstall `pkg_url`
346/// derivation ([`derive_overrides`]) and the `curl | sh` remote installer's
347/// per-`os-arch` `case` table ([`crate::installer::render_installer_cases`]):
348/// both must resolve to the same asset or a consumer hits a 404, so both render
349/// through the *same* `archive.name_template` + `format_overrides` the archive
350/// stage uses (via [`crate::archive_name`]), with each target's amd64
351/// micro-arch level derived from the build env
352/// ([`crate::build_env::config_time_amd64_variant`]) so a v2/v3-tuned group's
353/// suffixed asset name is reproduced too. Returns `None` when the crate
354/// exposes no binstallable archive or produces no targets.
355pub fn crate_archive_asset_names(
356    crate_cfg: &CrateConfig,
357    default_targets: &[String],
358    ctx: &mut Context,
359) -> Result<Option<BTreeMap<String, ArchiveAssetName>>> {
360    let Some(archive) = binstallable_archive(crate_cfg) else {
361        return Ok(None);
362    };
363    let targets = derive_target_list(crate_cfg, default_targets);
364    if targets.is_empty() {
365        return Ok(None);
366    }
367
368    // The name template the archive stage will use for this crate (user's
369    // `name_template:` wins; otherwise the canonical default — multi-crate when
370    // the archive stage's own work list holds more than one crate).
371    let name_template = archive
372        .name_template
373        .clone()
374        .unwrap_or_else(|| default_archive_name_template(ctx));
375    let global_default_format = global_default_archive_format(ctx);
376
377    let mut map: BTreeMap<String, ArchiveAssetName> = BTreeMap::new();
378    for target in &targets {
379        let format = archive_format_for_target(&archive, target, &global_default_format);
380        // The archive stage names a v2/v3-tuned group's asset with the amd64
381        // micro-arch level detected from the build env; derive the same level
382        // from config so the derived name cannot silently fall to the
383        // baseline (the binstall/installer 404 class).
384        let amd64_variant =
385            crate::build_env::config_time_amd64_variant(crate_cfg, target, default_targets, ctx)?;
386        let asset_name = render_archive_asset_name_with_variant(
387            ctx,
388            &name_template,
389            target,
390            &format,
391            amd64_variant.as_deref(),
392        )?;
393        map.insert(target.clone(), ArchiveAssetName { format, asset_name });
394    }
395    Ok(Some(map))
396}
397
398/// Resolve the release repo `(owner, repo, download_base)` for `crate_cfg`,
399/// rendering any templates in the owner/name. Returns `None` when no GitHub /
400/// GitLab / Gitea release repo is configured (auto-derivation cannot build a
401/// download URL without one).
402fn release_repo(crate_cfg: &CrateConfig, ctx: &Context) -> Option<(String, String, String)> {
403    let release = crate_cfg.release.as_ref()?;
404    // GitHub is the default download host; GitLab/Gitea use the same
405    // `<base>/<owner>/<repo>/releases/download/<tag>/<asset>` path shape, only
406    // the host differs. anodize's own config uses GitHub.
407    let (repo_cfg, base) = if let Some(gh) = release.github.as_ref() {
408        (gh, "https://github.com")
409    } else if let Some(gl) = release.gitlab.as_ref() {
410        (gl, "https://gitlab.com")
411    } else if let Some(gt) = release.gitea.as_ref() {
412        (gt, "https://gitea.com")
413    } else {
414        return None;
415    };
416    let owner = ctx.render_template(&repo_cfg.owner).ok()?;
417    let repo = ctx.render_template(&repo_cfg.name).ok()?;
418    if owner.is_empty() || repo.is_empty() {
419        return None;
420    }
421    Some((owner, repo, base.to_string()))
422}
423
424/// Pick the archive entry cargo-binstall should install from: the first entry
425/// whose default format is binstallable (tar.gz / zip / …),
426/// the primary archive is the one consumers fetch; auxiliary entries (e.g. a
427/// `tar.xz`/`tar.zst` `-extra` entry) are skipped.
428pub(crate) fn binstallable_archive(crate_cfg: &CrateConfig) -> Option<ArchiveConfig> {
429    let ArchivesConfig::Configs(configs) = &crate_cfg.archives else {
430        return None;
431    };
432    configs
433        .iter()
434        .find(|a| {
435            a.formats
436                .as_ref()
437                .and_then(|f| f.first())
438                .map(|fmt| binstall_pkg_fmt(fmt).is_some())
439                .unwrap_or(true)
440        })
441        .cloned()
442        .or_else(|| configs.first().cloned())
443}
444
445/// Resolve the full set of target triples binstall metadata must cover for
446/// `crate_cfg`: the union of each producing build's targets (a build's own
447/// `targets:` when set, else the global `default_targets`), de-duplicated.
448/// Routed through the build-synthesis SSOT
449/// ([`crate::build_plan::crate_target_list`]) so the derived override set equals
450/// the asset set the build stage actually releases — a library crate with no
451/// default binary (or a `binary: None` build with no matching `--bin`) compiles
452/// nothing and so contributes no targets.
453fn derive_target_list(crate_cfg: &CrateConfig, default_targets: &[String]) -> Vec<String> {
454    crate::build_plan::crate_target_list(crate_cfg, default_targets)
455}
456
457/// Render the crate's tag template with the version expressed as
458/// cargo-binstall's `{ version }` token. Stamps the [`VERSION_SENTINEL`] as the
459/// version, renders, then swaps the sentinel for `{ version }`.
460fn render_tag_with_version_token(crate_cfg: &CrateConfig, ctx: &mut Context) -> Result<String> {
461    // Prefer an explicit `release.tag` override; otherwise the crate's
462    // `tag_template` (defaulting to `v{{ Version }}` shape when unset).
463    let tag_template = crate_cfg
464        .release
465        .as_ref()
466        .and_then(|r| r.tag.clone())
467        .filter(|t| !t.is_empty())
468        .or_else(|| Some(crate_cfg.tag_template.clone()).filter(|t| !t.is_empty()))
469        .unwrap_or_else(|| "v{{ Version }}".to_string());
470
471    let prior = stamp_sentinel_version(ctx);
472    let rendered = ctx.render_template(&tag_template);
473    restore_version(ctx, prior);
474    let rendered = rendered
475        .with_context(|| format!("failed to render binstall tag template: {tag_template}"))?;
476    Ok(rendered.replace(VERSION_SENTINEL, "{ version }"))
477}
478
479/// Stamp the version-related template vars (`Version`, `RawVersion`, `Tag`) to
480/// the sentinel and return the prior values for [`restore_version`]. Tag is
481/// stamped too so a `name_template` referencing `{{ Tag }}` also picks up the
482/// sentinel.
483fn stamp_sentinel_version(ctx: &mut Context) -> Vec<(&'static str, Option<String>)> {
484    let prior: Vec<(&'static str, Option<String>)> = ["Version", "RawVersion", "Tag"]
485        .iter()
486        .map(|k| (*k, ctx.template_vars().get(k).cloned()))
487        .collect();
488
489    let vars = ctx.template_vars_mut();
490    vars.set("Version", VERSION_SENTINEL);
491    vars.set("RawVersion", VERSION_SENTINEL);
492    // A literal `v<sentinel>` keeps `{{ Tag }}`-based name templates aligned
493    // with the `v{ version }` tag the download URL targets.
494    vars.set("Tag", &format!("v{VERSION_SENTINEL}"));
495    prior
496}
497
498/// Restore the version vars captured by [`stamp_sentinel_version`].
499fn restore_version(ctx: &mut Context, prior: Vec<(&'static str, Option<String>)>) {
500    let vars = ctx.template_vars_mut();
501    for (key, value) in prior {
502        match value {
503            Some(v) => vars.set(key, &v),
504            None => {
505                vars.unset(key);
506            }
507        }
508    }
509}
510
511/// The default `archive.name_template` the archive stage uses for this crate:
512/// the multi-crate default when the archive stage's work list holds more than
513/// one crate, else the single-crate default. Resolves through the same
514/// selection the stage itself uses ([`crate::archive_selection`]) so the
515/// decision basis cannot drift from the stage's real work list — a
516/// workspace-only crate counts here exactly as it does there.
517fn default_archive_name_template(ctx: &Context) -> String {
518    let producing = crate::archive_selection::archive_producing_crates(
519        &ctx.config,
520        &ctx.artifacts,
521        &ctx.options.selected_crates,
522    );
523    if producing.len() > 1 {
524        crate::archive_name::DEFAULT_NAME_TEMPLATE_MULTI_CRATE.to_string()
525    } else {
526        crate::archive_name::DEFAULT_NAME_TEMPLATE.to_string()
527    }
528}
529
530/// The project-wide default archive format (`defaults.archives.formats[0]`,
531/// falling back to `tar.gz`). Used when an archive entry sets no `formats:`.
532fn global_default_archive_format(ctx: &Context) -> String {
533    ctx.config
534        .defaults
535        .as_ref()
536        .and_then(|d| d.archives.as_ref())
537        .and_then(|a| a.formats.as_ref())
538        .and_then(|f| f.first())
539        .cloned()
540        .unwrap_or_else(|| "tar.gz".to_string())
541}
542
543/// The archive format an entry produces for `target`: the first matching
544/// `format_overrides[]` entry's format (OS-prefix match, mirroring the archive
545/// stage), else the entry's own first `formats[]`, else `global_default`.
546fn archive_format_for_target(
547    archive: &ArchiveConfig,
548    target: &str,
549    global_default: &str,
550) -> String {
551    let (os, _arch) = map_target(target);
552    if let Some(overrides) = archive.format_overrides.as_ref() {
553        for ov in overrides {
554            if !ov.os.is_empty()
555                && os.starts_with(&ov.os)
556                && let Some(fmts) = ov.formats.as_ref()
557                && let Some(first) = fmts.first()
558            {
559                return first.clone();
560            }
561        }
562    }
563    archive
564        .formats
565        .as_ref()
566        .and_then(|f| f.first())
567        .cloned()
568        .unwrap_or_else(|| global_default.to_string())
569}
570
571#[cfg(test)]
572mod tests {
573    use std::collections::{BTreeMap, HashMap};
574
575    use super::*;
576    use crate::config::{
577        ArchiveConfig, ArchivesConfig, BinstallConfig, BinstallOverride, BuildConfig, Config,
578        FormatOverride, GitHubConfig, ReleaseConfig,
579    };
580    use crate::context::{Context, ContextOptions};
581
582    fn make_ctx() -> Context {
583        let config = Config {
584            project_name: "myapp".to_string(),
585            ..Default::default()
586        };
587        let mut ctx = Context::new(config, ContextOptions::default());
588        // Variant derivation consults the process env at every cargo flags
589        // tier; seal so an exported RUSTFLAGS cannot leak into fixtures.
590        ctx.set_env_source(crate::MapEnvSource::new());
591        ctx.template_vars_mut().set("Version", "1.0.0");
592        ctx.template_vars_mut().set("ProjectName", "myapp");
593        ctx
594    }
595
596    /// Drive [`generate_binstall_metadata`] against a temp-dir crate with the
597    /// given binstall config. The synthesized `CrateConfig` carries no archives
598    /// / release / builds, so auto-derivation is inert unless the test supplies
599    /// a richer crate via [`gen_with_crate`] — matching the bare
600    /// `(path, cfg, ctx)` call shape the surrounding tests use.
601    fn gen_meta(path: &str, cfg: &BinstallConfig, ctx: &mut Context, dry_run: bool) -> Result<()> {
602        let crate_cfg = CrateConfig {
603            name: "myapp".to_string(),
604            path: path.to_string(),
605            ..Default::default()
606        };
607        generate_binstall_metadata(&crate_cfg, cfg, &[], ctx, dry_run)
608    }
609
610    /// Like [`gen`] but with a caller-supplied `CrateConfig` (path is forced to
611    /// `path`) and an explicit `default_targets` list, for auto-derivation tests.
612    fn gen_with_crate(
613        path: &str,
614        mut crate_cfg: CrateConfig,
615        cfg: &BinstallConfig,
616        default_targets: &[String],
617        ctx: &mut Context,
618    ) -> Result<()> {
619        crate_cfg.path = path.to_string();
620        generate_binstall_metadata(&crate_cfg, cfg, default_targets, ctx, false)
621    }
622
623    #[test]
624    fn test_generate_binstall_metadata_inserts_section() {
625        let tmp = tempfile::tempdir().unwrap();
626        let cargo_toml = tmp.path().join("Cargo.toml");
627        std::fs::write(
628            &cargo_toml,
629            r#"[package]
630name = "myapp"
631version = "1.0.0"
632edition = "2024"
633"#,
634        )
635        .unwrap();
636
637        let binstall_cfg = BinstallConfig {
638            enabled: Some(true),
639            pkg_url: Some(
640                "https://github.com/myorg/myapp/releases/download/v{{ .Version }}/myapp-{{ .Version }}-{ target }.tar.gz"
641                    .to_string(),
642            ),
643            bin_dir: Some("{ bin }{ binary-ext }".to_string()),
644            pkg_fmt: Some("tgz".to_string()),
645            overrides: None,
646        };
647
648        let mut ctx = make_ctx();
649        gen_meta(tmp.path().to_str().unwrap(), &binstall_cfg, &mut ctx, false).unwrap();
650
651        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
652        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
653
654        let binstall = &doc["package"]["metadata"]["binstall"];
655        assert!(
656            binstall.as_table().is_some(),
657            "binstall section should exist as a table"
658        );
659        assert_eq!(binstall["pkg-fmt"].as_str().unwrap(), "tgz");
660        assert_eq!(
661            binstall["bin-dir"].as_str().unwrap(),
662            "{ bin }{ binary-ext }"
663        );
664        // The pkg-url should have the template variable rendered
665        let pkg_url = binstall["pkg-url"].as_str().unwrap();
666        assert!(
667            pkg_url.contains("1.0.0"),
668            "pkg-url should have Version rendered, got: {pkg_url}"
669        );
670    }
671
672    #[test]
673    fn test_generate_binstall_metadata_dry_run() {
674        let tmp = tempfile::tempdir().unwrap();
675        let cargo_toml = tmp.path().join("Cargo.toml");
676        let original = r#"[package]
677name = "myapp"
678version = "1.0.0"
679edition = "2024"
680"#;
681        std::fs::write(&cargo_toml, original).unwrap();
682
683        let binstall_cfg = BinstallConfig {
684            enabled: Some(true),
685            pkg_url: Some("https://example.com".to_string()),
686            bin_dir: None,
687            pkg_fmt: None,
688            overrides: None,
689        };
690
691        let mut ctx = make_ctx();
692        gen_meta(tmp.path().to_str().unwrap(), &binstall_cfg, &mut ctx, true).unwrap();
693
694        // File should be unchanged in dry-run mode
695        let content = std::fs::read_to_string(&cargo_toml).unwrap();
696        assert_eq!(content, original);
697    }
698
699    #[test]
700    fn test_generate_binstall_metadata_missing_cargo_toml_errors() {
701        let tmp = tempfile::tempdir().unwrap();
702        let binstall_cfg = BinstallConfig {
703            enabled: Some(true),
704            pkg_url: None,
705            bin_dir: None,
706            pkg_fmt: None,
707            overrides: None,
708        };
709
710        let mut ctx = make_ctx();
711        let result = gen_meta(tmp.path().to_str().unwrap(), &binstall_cfg, &mut ctx, false);
712        assert!(result.is_err());
713    }
714
715    #[test]
716    fn test_generate_binstall_metadata_emits_per_target_overrides() {
717        let tmp = tempfile::tempdir().unwrap();
718        let cargo_toml = tmp.path().join("Cargo.toml");
719        std::fs::write(
720            &cargo_toml,
721            r#"[package]
722name = "cfgd"
723version = "1.0.0"
724edition = "2024"
725"#,
726        )
727        .unwrap();
728
729        let mut overrides = BTreeMap::new();
730        overrides.insert(
731            "x86_64-unknown-linux-gnu".to_string(),
732            BinstallOverride {
733                pkg_url: Some(
734                    "https://github.com/myorg/cfgd/releases/download/v{{ .Version }}/cfgd-{{ .Version }}-linux-amd64.tar.gz"
735                        .to_string(),
736                ),
737                pkg_fmt: Some("tgz".to_string()),
738                bin_dir: Some("{ bin }{ binary-ext }".to_string()),
739            },
740        );
741        overrides.insert(
742            "aarch64-apple-darwin".to_string(),
743            BinstallOverride {
744                pkg_url: Some(
745                    "https://github.com/myorg/cfgd/releases/download/v{{ .Version }}/cfgd-{ version }-darwin-arm64.tar.gz"
746                        .to_string(),
747                ),
748                pkg_fmt: Some("tgz".to_string()),
749                bin_dir: None,
750            },
751        );
752
753        let binstall_cfg = BinstallConfig {
754            enabled: Some(true),
755            pkg_url: None,
756            bin_dir: None,
757            pkg_fmt: None,
758            overrides: Some(overrides),
759        };
760
761        let mut ctx = make_ctx();
762        gen_meta(tmp.path().to_str().unwrap(), &binstall_cfg, &mut ctx, false).unwrap();
763
764        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
765        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
766
767        let overrides_item = &doc["package"]["metadata"]["binstall"]["overrides"];
768        assert!(
769            overrides_item.as_table().is_some(),
770            "binstall.overrides should exist as a table"
771        );
772
773        // linux-amd64 (go-arch) entry: Version rendered, asset name intact.
774        let linux = &overrides_item["x86_64-unknown-linux-gnu"];
775        assert!(
776            linux.as_table().is_some(),
777            "override sub-table should be a real table"
778        );
779        let linux_url = linux["pkg-url"].as_str().unwrap();
780        assert!(
781            linux_url.contains("cfgd-1.0.0-linux-amd64.tar.gz"),
782            "linux pkg-url should be Version-rendered with go-arch asset name, got: {linux_url}"
783        );
784        assert!(
785            !linux_url.contains("{{ .Version }}"),
786            "anodize token should be rendered, got: {linux_url}"
787        );
788        assert_eq!(linux["pkg-fmt"].as_str().unwrap(), "tgz");
789        assert_eq!(linux["bin-dir"].as_str().unwrap(), "{ bin }{ binary-ext }");
790
791        // darwin-arm64 (go-arch) entry.
792        let darwin = &overrides_item["aarch64-apple-darwin"];
793        assert!(darwin.as_table().is_some());
794        let darwin_url = darwin["pkg-url"].as_str().unwrap();
795        // The leading v{{ .Version }} is an anodize token (rendered) while
796        // `{ version }` is cargo-binstall's own token and must survive intact.
797        assert!(
798            darwin_url.contains("/v1.0.0/cfgd-{ version }-darwin-arm64.tar.gz"),
799            "darwin pkg-url should render the anodize token but leave cargo-binstall's `{{ version }}` intact, got: {darwin_url}"
800        );
801
802        // Triple keys contain `-` and must render as proper headers.
803        assert!(
804            updated.contains("[package.metadata.binstall.overrides.x86_64-unknown-linux-gnu]"),
805            "override should render as a [...] header, got:\n{updated}"
806        );
807    }
808
809    #[test]
810    fn test_generate_binstall_metadata_preserves_user_authored_keys() {
811        let tmp = tempfile::tempdir().unwrap();
812        let cargo_toml = tmp.path().join("Cargo.toml");
813        // Seed a Cargo.toml whose binstall table already carries keys anodize
814        // does NOT model: cargo-binstall's `disabled-strategies` and the
815        // `[package.metadata.binstall.signing]` sub-table. The in-place merge
816        // must leave both untouched while (re)writing pkg-url / overrides.
817        std::fs::write(
818            &cargo_toml,
819            r#"[package]
820name = "myapp"
821version = "1.0.0"
822edition = "2024"
823
824[package.metadata.binstall]
825disabled-strategies = ["quick-install", "compile"]
826pkg-url = "https://old.example.com/stale"
827
828[package.metadata.binstall.signing]
829algorithm = "minisign"
830pubkey = "RWQABCDEF1234567890"
831"#,
832        )
833        .unwrap();
834
835        let mut overrides = BTreeMap::new();
836        overrides.insert(
837            "x86_64-unknown-linux-gnu".to_string(),
838            BinstallOverride {
839                pkg_url: Some(
840                    "https://github.com/myorg/myapp/releases/download/v{{ .Version }}/myapp-linux.tar.gz"
841                        .to_string(),
842                ),
843                pkg_fmt: Some("tgz".to_string()),
844                bin_dir: None,
845            },
846        );
847
848        let binstall_cfg = BinstallConfig {
849            enabled: Some(true),
850            pkg_url: Some(
851                "https://github.com/myorg/myapp/releases/download/v{{ .Version }}/myapp-{ target }.tar.gz"
852                    .to_string(),
853            ),
854            bin_dir: None,
855            pkg_fmt: None,
856            overrides: Some(overrides),
857        };
858
859        let mut ctx = make_ctx();
860        gen_meta(tmp.path().to_str().unwrap(), &binstall_cfg, &mut ctx, false).unwrap();
861
862        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
863        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
864        let binstall = &doc["package"]["metadata"]["binstall"];
865
866        // Unknown keys survive verbatim.
867        let strategies = binstall["disabled-strategies"].as_array().unwrap();
868        let strategy_vals: Vec<&str> = strategies.iter().filter_map(|v| v.as_str()).collect();
869        assert_eq!(
870            strategy_vals,
871            vec!["quick-install", "compile"],
872            "disabled-strategies must survive the merge verbatim"
873        );
874
875        let signing = &binstall["signing"];
876        assert!(
877            signing.as_table().is_some(),
878            "signing sub-table must survive the merge"
879        );
880        assert_eq!(signing["algorithm"].as_str().unwrap(), "minisign");
881        assert_eq!(signing["pubkey"].as_str().unwrap(), "RWQABCDEF1234567890");
882
883        // anodize-owned keys are (re)written: pkg-url rendered to the new value.
884        let pkg_url = binstall["pkg-url"].as_str().unwrap();
885        assert!(
886            pkg_url.contains("/v1.0.0/myapp-{ target }.tar.gz"),
887            "pkg-url should be rewritten with the rendered Version, got: {pkg_url}"
888        );
889        assert!(
890            !pkg_url.contains("old.example.com"),
891            "stale anodize-owned pkg-url should be replaced, got: {pkg_url}"
892        );
893
894        // overrides is anodize-owned and freshly written.
895        let linux = &binstall["overrides"]["x86_64-unknown-linux-gnu"];
896        assert_eq!(linux["pkg-fmt"].as_str().unwrap(), "tgz");
897        assert!(
898            linux["pkg-url"]
899                .as_str()
900                .unwrap()
901                .contains("/v1.0.0/myapp-linux.tar.gz")
902        );
903    }
904
905    #[test]
906    fn test_generate_binstall_metadata_clears_unset_owned_key_keeps_unknown() {
907        let tmp = tempfile::tempdir().unwrap();
908        let cargo_toml = tmp.path().join("Cargo.toml");
909        // pkg-url present plus an unknown sibling key. Config omits pkg_url, so
910        // the owned key must be cleared while the unknown key survives.
911        std::fs::write(
912            &cargo_toml,
913            r#"[package]
914name = "myapp"
915version = "1.0.0"
916edition = "2024"
917
918[package.metadata.binstall]
919disabled-strategies = ["compile"]
920pkg-url = "https://old.example.com/stale"
921"#,
922        )
923        .unwrap();
924
925        let binstall_cfg = BinstallConfig {
926            enabled: Some(true),
927            pkg_url: None,
928            bin_dir: None,
929            pkg_fmt: None,
930            overrides: None,
931        };
932
933        let mut ctx = make_ctx();
934        gen_meta(tmp.path().to_str().unwrap(), &binstall_cfg, &mut ctx, false).unwrap();
935
936        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
937        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
938        let binstall = &doc["package"]["metadata"]["binstall"];
939
940        assert!(
941            binstall.get("pkg-url").is_none(),
942            "unset owned key should be cleared, got:\n{updated}"
943        );
944        let strategies = binstall["disabled-strategies"].as_array().unwrap();
945        assert_eq!(strategies.len(), 1, "unknown key must survive clearing");
946        assert_eq!(strategies.get(0).unwrap().as_str().unwrap(), "compile");
947    }
948
949    #[test]
950    fn test_generate_binstall_metadata_merges_inline_table_preserving_unknown() {
951        let tmp = tempfile::tempdir().unwrap();
952        let cargo_toml = tmp.path().join("Cargo.toml");
953        // A user with INLINE binstall metadata: `as_table_mut()` would return
954        // None on this. The merge must convert it to a header table while
955        // preserving the unknown `disabled-strategies` key.
956        std::fs::write(
957            &cargo_toml,
958            r#"[package]
959name = "myapp"
960version = "1.0.0"
961edition = "2024"
962metadata.binstall = { pkg-url = "https://old.example.com/stale", disabled-strategies = ["compile"] }
963"#,
964        )
965        .unwrap();
966
967        let binstall_cfg = BinstallConfig {
968            enabled: Some(true),
969            pkg_url: Some(
970                "https://github.com/myorg/myapp/releases/download/v{{ .Version }}/myapp-{ target }.tar.gz"
971                    .to_string(),
972            ),
973            bin_dir: None,
974            pkg_fmt: None,
975            overrides: None,
976        };
977
978        let mut ctx = make_ctx();
979        gen_meta(tmp.path().to_str().unwrap(), &binstall_cfg, &mut ctx, false).unwrap();
980
981        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
982        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
983        let binstall = &doc["package"]["metadata"]["binstall"];
984
985        // pkg-url rewritten with the rendered Version.
986        let pkg_url = binstall["pkg-url"].as_str().unwrap();
987        assert!(
988            pkg_url.contains("/v1.0.0/myapp-{ target }.tar.gz")
989                && !pkg_url.contains("old.example.com"),
990            "inline pkg-url should be rewritten, got: {pkg_url}"
991        );
992        // Unknown key carried over from the inline table.
993        let strategies = binstall["disabled-strategies"].as_array().unwrap();
994        assert_eq!(strategies.len(), 1);
995        assert_eq!(strategies.get(0).unwrap().as_str().unwrap(), "compile");
996    }
997
998    // -----------------------------------------------------------------------
999    // Auto-derivation
1000    // -----------------------------------------------------------------------
1001
1002    /// All six anodize triples, the matrix the auto-derivation must cover.
1003    fn six_targets() -> Vec<String> {
1004        vec![
1005            "x86_64-unknown-linux-gnu".to_string(),
1006            "aarch64-unknown-linux-gnu".to_string(),
1007            "x86_64-apple-darwin".to_string(),
1008            "aarch64-apple-darwin".to_string(),
1009            "x86_64-pc-windows-msvc".to_string(),
1010            "aarch64-pc-windows-msvc".to_string(),
1011        ]
1012    }
1013
1014    /// A crate mirroring anodize's binary crate: an explicit
1015    /// `name_template: "{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}"`,
1016    /// `formats: [tar.gz]` with a windows→zip override, and a GitHub release.
1017    fn anodize_like_crate() -> CrateConfig {
1018        let archive = ArchiveConfig {
1019            name_template: Some("{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}".to_string()),
1020            formats: Some(vec!["tar.gz".to_string()]),
1021            format_overrides: Some(vec![FormatOverride {
1022                os: "windows".to_string(),
1023                formats: Some(vec!["zip".to_string()]),
1024            }]),
1025            ..Default::default()
1026        };
1027        CrateConfig {
1028            name: "anodizer".to_string(),
1029            tag_template: "v{{ Version }}".to_string(),
1030            archives: ArchivesConfig::Configs(vec![archive]),
1031            release: Some(ReleaseConfig {
1032                github: Some(GitHubConfig {
1033                    owner: "tj-smith47".to_string(),
1034                    name: "anodizer".to_string(),
1035                }),
1036                ..Default::default()
1037            }),
1038            ..Default::default()
1039        }
1040    }
1041
1042    /// The asset filename a binstall override resolves to, with the
1043    /// cargo-binstall `{ version }` token substituted back to the test version.
1044    fn override_asset(binstall: &toml_edit::Item, triple: &str, version: &str) -> String {
1045        let url = binstall["overrides"][triple]["pkg-url"].as_str().unwrap();
1046        let leaf = url.rsplit('/').next().unwrap();
1047        leaf.replace("{ version }", version)
1048    }
1049
1050    #[test]
1051    fn auto_derive_covers_all_six_triples_matching_archive_names() {
1052        let tmp = tempfile::tempdir().unwrap();
1053        let cargo_toml = tmp.path().join("Cargo.toml");
1054        std::fs::write(
1055            &cargo_toml,
1056            "[package]\nname = \"anodizer\"\nversion = \"1.0.0\"\nedition = \"2024\"\n",
1057        )
1058        .unwrap();
1059        // A binstallable crate is a binary crate; declare the `--bin` so the
1060        // build-synthesis gate the override set now routes through sees a
1061        // producing default build (otherwise a no-bin crate derives nothing).
1062        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
1063        std::fs::write(tmp.path().join("src/main.rs"), "fn main() {}\n").unwrap();
1064
1065        // enabled: true, NOTHING else — the whole point.
1066        let cfg = BinstallConfig {
1067            enabled: Some(true),
1068            ..Default::default()
1069        };
1070        let mut ctx = make_ctx();
1071        ctx.template_vars_mut().set("ProjectName", "anodizer");
1072        let targets = six_targets();
1073        gen_with_crate(
1074            tmp.path().to_str().unwrap(),
1075            anodize_like_crate(),
1076            &cfg,
1077            &targets,
1078            &mut ctx,
1079        )
1080        .unwrap();
1081
1082        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
1083        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
1084        let binstall = &doc["package"]["metadata"]["binstall"];
1085
1086        // Every triple got an override, and each override's asset name EQUALS
1087        // what the shared archive name-rendering produces for that target —
1088        // the byte-equality that kills the 404 class. Render the expectation
1089        // through the SAME core function the archive stage uses.
1090        let mut check_ctx = make_ctx();
1091        check_ctx.template_vars_mut().set("ProjectName", "anodizer");
1092        let name_tmpl = "{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}";
1093        for triple in &targets {
1094            let fmt = if triple.contains("windows") {
1095                "zip"
1096            } else {
1097                "tar.gz"
1098            };
1099            let expected = crate::archive_name::render_archive_asset_name(
1100                &mut check_ctx,
1101                name_tmpl,
1102                triple,
1103                fmt,
1104            )
1105            .unwrap();
1106            let actual = override_asset(binstall, triple, "1.0.0");
1107            assert_eq!(
1108                actual, expected,
1109                "override asset for {triple} must equal the archive stage's name"
1110            );
1111
1112            // pkg-fmt matches the format.
1113            let pkg_fmt = binstall["overrides"][triple]["pkg-fmt"].as_str().unwrap();
1114            assert_eq!(pkg_fmt, if fmt == "zip" { "zip" } else { "tgz" });
1115
1116            // cargo-binstall's `{ version }` token is present (resolves per
1117            // install), and the full download URL is well-formed.
1118            let url = binstall["overrides"][triple]["pkg-url"].as_str().unwrap();
1119            assert!(
1120                url.starts_with(
1121                    "https://github.com/tj-smith47/anodizer/releases/download/v{ version }/"
1122                ),
1123                "url should target the GitHub release download path with the binstall version token, got: {url}"
1124            );
1125            assert!(
1126                url.contains("{ version }"),
1127                "url must carry cargo-binstall's version token, got: {url}"
1128            );
1129        }
1130
1131        // Concrete spot-checks for the two endpoints.
1132        assert_eq!(
1133            override_asset(binstall, "x86_64-unknown-linux-gnu", "9.9.9"),
1134            "anodizer-9.9.9-linux-amd64.tar.gz"
1135        );
1136        assert_eq!(
1137            override_asset(binstall, "aarch64-pc-windows-msvc", "9.9.9"),
1138            "anodizer-9.9.9-windows-arm64.zip"
1139        );
1140
1141        // No stale top-level pkg-url leaks in.
1142        assert!(
1143            binstall.get("pkg-url").is_none(),
1144            "auto-derivation must not write a top-level pkg-url, got:\n{updated}"
1145        );
1146    }
1147
1148    #[test]
1149    fn auto_derive_matches_real_v091_release_assets() {
1150        // Real-world guard: anodize's own crate config (owner/repo/name_template/
1151        // tag_template from `.anodizer.yaml`) must auto-derive overrides whose
1152        // `pkg-url`s resolve, for a concrete version, to the EXACT asset names
1153        // the v0.9.1 GitHub release uploaded. A drift here is the "cargo binstall
1154        // 404 / source-compile" bug this whole path exists to prevent. The
1155        // expected strings are the literal v0.9.1 release assets, hand-written so
1156        // a refactor of the rendering can't move both sides in lockstep.
1157        let tmp = tempfile::tempdir().unwrap();
1158        let cargo_toml = tmp.path().join("Cargo.toml");
1159        std::fs::write(
1160            &cargo_toml,
1161            "[package]\nname = \"anodizer\"\nversion = \"0.9.1\"\nedition = \"2024\"\n",
1162        )
1163        .unwrap();
1164        // Declare the `--bin` so the build-synthesis gate sees a producing
1165        // default build (a no-bin crate derives no binstall targets).
1166        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
1167        std::fs::write(tmp.path().join("src/main.rs"), "fn main() {}\n").unwrap();
1168
1169        let cfg = BinstallConfig {
1170            enabled: Some(true),
1171            ..Default::default()
1172        };
1173        let mut ctx = make_ctx();
1174        ctx.template_vars_mut().set("ProjectName", "anodizer");
1175        gen_with_crate(
1176            tmp.path().to_str().unwrap(),
1177            anodize_like_crate(),
1178            &cfg,
1179            &six_targets(),
1180            &mut ctx,
1181        )
1182        .unwrap();
1183
1184        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
1185        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
1186        let binstall = &doc["package"]["metadata"]["binstall"];
1187
1188        // (triple, real v0.9.1 asset name, real v0.9.1 pkg-fmt)
1189        let expected: &[(&str, &str, &str)] = &[
1190            (
1191                "x86_64-unknown-linux-gnu",
1192                "anodizer-0.9.1-linux-amd64.tar.gz",
1193                "tgz",
1194            ),
1195            (
1196                "aarch64-unknown-linux-gnu",
1197                "anodizer-0.9.1-linux-arm64.tar.gz",
1198                "tgz",
1199            ),
1200            (
1201                "x86_64-apple-darwin",
1202                "anodizer-0.9.1-darwin-amd64.tar.gz",
1203                "tgz",
1204            ),
1205            (
1206                "aarch64-apple-darwin",
1207                "anodizer-0.9.1-darwin-arm64.tar.gz",
1208                "tgz",
1209            ),
1210            (
1211                "x86_64-pc-windows-msvc",
1212                "anodizer-0.9.1-windows-amd64.zip",
1213                "zip",
1214            ),
1215            (
1216                "aarch64-pc-windows-msvc",
1217                "anodizer-0.9.1-windows-arm64.zip",
1218                "zip",
1219            ),
1220        ];
1221
1222        for (triple, asset, fmt) in expected {
1223            let url = binstall["overrides"][*triple]["pkg-url"].as_str().unwrap();
1224            // The override carries cargo-binstall's `{ version }` token; resolved
1225            // for 0.9.1 it must equal the real release download URL byte-for-byte.
1226            let resolved = url.replace("{ version }", "0.9.1");
1227            let want =
1228                format!("https://github.com/tj-smith47/anodizer/releases/download/v0.9.1/{asset}");
1229            assert_eq!(
1230                resolved, want,
1231                "override for {triple} must resolve to the real v0.9.1 asset URL"
1232            );
1233            assert_eq!(
1234                binstall["overrides"][*triple]["pkg-fmt"].as_str().unwrap(),
1235                *fmt,
1236                "pkg-fmt for {triple} must match the real v0.9.1 asset format"
1237            );
1238        }
1239    }
1240
1241    #[test]
1242    fn user_pkg_url_suppresses_auto_derivation() {
1243        let tmp = tempfile::tempdir().unwrap();
1244        let cargo_toml = tmp.path().join("Cargo.toml");
1245        std::fs::write(
1246            &cargo_toml,
1247            "[package]\nname = \"anodizer\"\nversion = \"1.0.0\"\nedition = \"2024\"\n",
1248        )
1249        .unwrap();
1250
1251        let cfg = BinstallConfig {
1252            enabled: Some(true),
1253            pkg_url: Some("https://example.com/custom/anodizer-{ target }.tar.gz".to_string()),
1254            ..Default::default()
1255        };
1256        let mut ctx = make_ctx();
1257        ctx.template_vars_mut().set("ProjectName", "anodizer");
1258        gen_with_crate(
1259            tmp.path().to_str().unwrap(),
1260            anodize_like_crate(),
1261            &cfg,
1262            &six_targets(),
1263            &mut ctx,
1264        )
1265        .unwrap();
1266
1267        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
1268        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
1269        let binstall = &doc["package"]["metadata"]["binstall"];
1270
1271        // Manual pkg-url wins; NO auto-derived overrides table is emitted.
1272        assert_eq!(
1273            binstall["pkg-url"].as_str().unwrap(),
1274            "https://example.com/custom/anodizer-{ target }.tar.gz"
1275        );
1276        assert!(
1277            binstall.get("overrides").is_none(),
1278            "a user pkg_url must suppress auto-derived overrides, got:\n{updated}"
1279        );
1280    }
1281
1282    #[test]
1283    fn user_override_suppresses_auto_derivation() {
1284        let tmp = tempfile::tempdir().unwrap();
1285        let cargo_toml = tmp.path().join("Cargo.toml");
1286        std::fs::write(
1287            &cargo_toml,
1288            "[package]\nname = \"anodizer\"\nversion = \"1.0.0\"\nedition = \"2024\"\n",
1289        )
1290        .unwrap();
1291
1292        let mut user_overrides = BTreeMap::new();
1293        user_overrides.insert(
1294            "x86_64-unknown-linux-gnu".to_string(),
1295            BinstallOverride {
1296                pkg_url: Some("https://example.com/manual-linux.tar.gz".to_string()),
1297                pkg_fmt: Some("tgz".to_string()),
1298                bin_dir: None,
1299            },
1300        );
1301        let cfg = BinstallConfig {
1302            enabled: Some(true),
1303            overrides: Some(user_overrides),
1304            ..Default::default()
1305        };
1306        let mut ctx = make_ctx();
1307        ctx.template_vars_mut().set("ProjectName", "anodizer");
1308        gen_with_crate(
1309            tmp.path().to_str().unwrap(),
1310            anodize_like_crate(),
1311            &cfg,
1312            &six_targets(),
1313            &mut ctx,
1314        )
1315        .unwrap();
1316
1317        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
1318        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
1319        let overrides = &doc["package"]["metadata"]["binstall"]["overrides"];
1320
1321        // ONLY the user's single override is emitted — auto-derivation does not
1322        // add the other five triples.
1323        assert_eq!(
1324            overrides["x86_64-unknown-linux-gnu"]["pkg-url"]
1325                .as_str()
1326                .unwrap(),
1327            "https://example.com/manual-linux.tar.gz"
1328        );
1329        assert!(
1330            overrides.get("aarch64-apple-darwin").is_none(),
1331            "supplying one override must suppress auto-derivation of the rest, got:\n{updated}"
1332        );
1333    }
1334
1335    #[test]
1336    fn auto_derive_uses_default_template_when_unset() {
1337        // A crate with binstall enabled but NO explicit archive name_template
1338        // must derive against the canonical default (single-crate) template.
1339        let tmp = tempfile::tempdir().unwrap();
1340        let cargo_toml = tmp.path().join("Cargo.toml");
1341        std::fs::write(
1342            &cargo_toml,
1343            "[package]\nname = \"myapp\"\nversion = \"1.0.0\"\nedition = \"2024\"\n",
1344        )
1345        .unwrap();
1346
1347        // Declare the `--bin` so the build-synthesis gate sees a producing
1348        // default build (a no-bin crate derives no binstall targets).
1349        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
1350        std::fs::write(tmp.path().join("src/main.rs"), "fn main() {}\n").unwrap();
1351
1352        let mut crate_cfg = anodize_like_crate();
1353        crate_cfg.name = "myapp".to_string();
1354        // Archive with formats but no name_template.
1355        crate_cfg.archives = ArchivesConfig::Configs(vec![ArchiveConfig {
1356            formats: Some(vec!["tar.gz".to_string()]),
1357            ..Default::default()
1358        }]);
1359
1360        let cfg = BinstallConfig {
1361            enabled: Some(true),
1362            ..Default::default()
1363        };
1364        let mut ctx = make_ctx();
1365        gen_with_crate(
1366            tmp.path().to_str().unwrap(),
1367            crate_cfg,
1368            &cfg,
1369            &["x86_64-unknown-linux-gnu".to_string()],
1370            &mut ctx,
1371        )
1372        .unwrap();
1373
1374        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
1375        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
1376        let binstall = &doc["package"]["metadata"]["binstall"];
1377        // Default template → `myapp_1.0.0_linux_amd64.tar.gz`.
1378        assert_eq!(
1379            override_asset(binstall, "x86_64-unknown-linux-gnu", "1.0.0"),
1380            "myapp_1.0.0_linux_amd64.tar.gz"
1381        );
1382    }
1383
1384    #[test]
1385    fn auto_derive_noops_without_release_repo() {
1386        // No release repo → no download URL can be built → no overrides.
1387        let tmp = tempfile::tempdir().unwrap();
1388        let cargo_toml = tmp.path().join("Cargo.toml");
1389        std::fs::write(
1390            &cargo_toml,
1391            "[package]\nname = \"myapp\"\nversion = \"1.0.0\"\nedition = \"2024\"\n",
1392        )
1393        .unwrap();
1394
1395        let mut crate_cfg = anodize_like_crate();
1396        crate_cfg.name = "myapp".to_string();
1397        crate_cfg.release = None;
1398
1399        let cfg = BinstallConfig {
1400            enabled: Some(true),
1401            ..Default::default()
1402        };
1403        let mut ctx = make_ctx();
1404        gen_with_crate(
1405            tmp.path().to_str().unwrap(),
1406            crate_cfg,
1407            &cfg,
1408            &six_targets(),
1409            &mut ctx,
1410        )
1411        .unwrap();
1412
1413        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
1414        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
1415        let binstall = &doc["package"]["metadata"]["binstall"];
1416        assert!(
1417            binstall.get("overrides").is_none(),
1418            "no release repo should mean no auto-derived overrides, got:\n{updated}"
1419        );
1420    }
1421
1422    // --- normalize_binstall_to_table --------------------------------------
1423
1424    #[test]
1425    fn normalize_inserts_empty_table_when_binstall_absent() {
1426        let mut metadata = toml_edit::Table::new();
1427        normalize_binstall_to_table(&mut metadata).unwrap();
1428        let binstall = metadata.get("binstall").expect("binstall inserted");
1429        assert!(binstall.is_table(), "must be a header table");
1430        assert!(
1431            binstall.as_table().unwrap().is_empty(),
1432            "freshly-inserted binstall table must be empty"
1433        );
1434    }
1435
1436    #[test]
1437    fn normalize_leaves_existing_header_table_untouched() {
1438        let mut metadata = toml_edit::Table::new();
1439        let mut existing = toml_edit::Table::new();
1440        existing.insert("pkg-url", toml_edit::value("https://example/x"));
1441        metadata.insert("binstall", toml_edit::Item::Table(existing));
1442        normalize_binstall_to_table(&mut metadata).unwrap();
1443        assert_eq!(
1444            metadata["binstall"]["pkg-url"].as_str(),
1445            Some("https://example/x"),
1446            "an existing header table must be preserved verbatim"
1447        );
1448    }
1449
1450    #[test]
1451    fn normalize_converts_inline_table_preserving_keys() {
1452        // `binstall = { pkg-url = "…", custom = "keep" }` → header table with
1453        // every key carried over (including the user-authored `custom`).
1454        let doc: toml_edit::DocumentMut = r#"[package.metadata]
1455binstall = { pkg-url = "https://example/x", custom = "keep" }
1456"#
1457        .parse()
1458        .unwrap();
1459        let mut metadata = doc["package"]["metadata"].as_table().unwrap().clone();
1460        normalize_binstall_to_table(&mut metadata).unwrap();
1461        let binstall = metadata["binstall"].as_table().expect("now a header table");
1462        assert_eq!(binstall["pkg-url"].as_str(), Some("https://example/x"));
1463        assert_eq!(
1464            binstall["custom"].as_str(),
1465            Some("keep"),
1466            "user-authored inline keys must survive the conversion"
1467        );
1468    }
1469
1470    #[test]
1471    fn normalize_errors_on_non_table_binstall() {
1472        // A scalar `binstall = "oops"` is neither a table nor an inline table.
1473        let mut metadata = toml_edit::Table::new();
1474        metadata.insert("binstall", toml_edit::value("oops"));
1475        assert!(
1476            normalize_binstall_to_table(&mut metadata).is_err(),
1477            "a malformed scalar binstall must error, not silently pass"
1478        );
1479    }
1480
1481    // --- set_or_remove_str ------------------------------------------------
1482
1483    #[test]
1484    fn set_or_remove_str_sets_and_removes() {
1485        let mut table = toml_edit::Table::new();
1486        set_or_remove_str(&mut table, "k", Some("v"));
1487        assert_eq!(table["k"].as_str(), Some("v"));
1488        // A `None` value removes a now-unset key while leaving siblings intact.
1489        table.insert("sibling", toml_edit::value("keep"));
1490        set_or_remove_str(&mut table, "k", None);
1491        assert!(table.get("k").is_none(), "None must remove the key");
1492        assert_eq!(
1493            table["sibling"].as_str(),
1494            Some("keep"),
1495            "removing one key must not disturb siblings"
1496        );
1497    }
1498
1499    // --- render_overrides / render_overrides_map --------------------------
1500
1501    #[test]
1502    fn render_overrides_none_when_unset_or_empty() {
1503        let ctx = make_ctx();
1504        let cfg = BinstallConfig {
1505            overrides: None,
1506            ..Default::default()
1507        };
1508        assert!(
1509            render_overrides(&cfg, &ctx).unwrap().is_none(),
1510            "no overrides configured → None"
1511        );
1512        let cfg_empty = BinstallConfig {
1513            overrides: Some(BTreeMap::new()),
1514            ..Default::default()
1515        };
1516        assert!(
1517            render_overrides(&cfg_empty, &ctx).unwrap().is_none(),
1518            "an empty overrides map → None"
1519        );
1520    }
1521
1522    #[test]
1523    fn render_overrides_map_empty_is_none() {
1524        let ctx = make_ctx();
1525        let empty: BTreeMap<String, BinstallOverride> = BTreeMap::new();
1526        assert!(
1527            render_overrides_map(&empty, &ctx).unwrap().is_none(),
1528            "an empty map renders to None, never an empty table"
1529        );
1530    }
1531
1532    // --- variant-aware config-time derivation ------------------------------
1533
1534    /// A crate whose builds carry per-target env, against the DEFAULT archive
1535    /// name template, so the derived names exercise the `Amd64` suffix clause.
1536    fn tuned_crate(env: HashMap<String, HashMap<String, String>>) -> CrateConfig {
1537        CrateConfig {
1538            name: "myapp".to_string(),
1539            builds: Some(vec![BuildConfig {
1540                binary: Some("myapp".to_string()),
1541                targets: Some(vec![
1542                    "x86_64-unknown-linux-gnu".to_string(),
1543                    "aarch64-unknown-linux-gnu".to_string(),
1544                    "x86_64-apple-darwin".to_string(),
1545                ]),
1546                env: Some(env),
1547                ..Default::default()
1548            }]),
1549            archives: ArchivesConfig::Configs(vec![ArchiveConfig {
1550                formats: Some(vec!["tar.gz".to_string()]),
1551                ..Default::default()
1552            }]),
1553            ..Default::default()
1554        }
1555    }
1556
1557    /// A v3-tuned build (`RUSTFLAGS -Ctarget-cpu=x86-64-v3` in the per-target
1558    /// build env) must derive the SAME `amd64v3` asset name the archive stage
1559    /// uploads for that group — with zero manual overrides. Targets without
1560    /// the tuning env (the darwin amd64 build here) keep the baseline
1561    /// suffix-free name, proving the variant is keyed per target, not
1562    /// per crate.
1563    #[test]
1564    fn asset_names_carry_config_declared_amd64_variant() {
1565        let mut env = HashMap::new();
1566        env.insert(
1567            "x86_64-unknown-linux-gnu".to_string(),
1568            HashMap::from([(
1569                "RUSTFLAGS".to_string(),
1570                "-Ctarget-cpu=x86-64-v3".to_string(),
1571            )]),
1572        );
1573        let crate_cfg = tuned_crate(env);
1574        let mut ctx = make_ctx();
1575        let assets = crate_archive_asset_names(&crate_cfg, &[], &mut ctx)
1576            .unwrap()
1577            .expect("binstallable archive with targets derives names");
1578        assert_eq!(
1579            assets["x86_64-unknown-linux-gnu"].asset_name, "myapp_1.0.0_linux_amd64v3.tar.gz",
1580            "tuned target must carry the micro-arch suffix"
1581        );
1582        assert_eq!(
1583            assets["aarch64-unknown-linux-gnu"].asset_name,
1584            "myapp_1.0.0_linux_arm64.tar.gz"
1585        );
1586        assert_eq!(
1587            assets["x86_64-apple-darwin"].asset_name, "myapp_1.0.0_darwin_amd64.tar.gz",
1588            "untuned amd64 target stays on the baseline name"
1589        );
1590    }
1591
1592    /// A per-target RUSTFLAGS templated on the planner-seeded `Os` var must
1593    /// derive the right level for EVERY target of the loop. The regression
1594    /// shape: without per-target seeding inside the projection, target 1
1595    /// (linux) rendered with no `Os` at all (raw fallback → baseline) and
1596    /// target 2 (darwin) rendered with target 1's STALE `Os` (silently the
1597    /// wrong target's level).
1598    #[test]
1599    fn os_templated_tuning_env_derives_per_target_levels_across_the_loop() {
1600        let mut env = HashMap::new();
1601        env.insert(
1602            "x86_64-*".to_string(),
1603            HashMap::from([(
1604                "RUSTFLAGS".to_string(),
1605                "{% if Os == \"darwin\" %}-Ctarget-cpu=x86-64-v2\
1606                 {% else %}-Ctarget-cpu=x86-64-v3{% endif %}"
1607                    .to_string(),
1608            )]),
1609        );
1610        let crate_cfg = tuned_crate(env);
1611        let mut ctx = make_ctx();
1612        let assets = crate_archive_asset_names(&crate_cfg, &[], &mut ctx)
1613            .unwrap()
1614            .expect("binstallable archive with targets derives names");
1615        assert_eq!(
1616            assets["x86_64-unknown-linux-gnu"].asset_name, "myapp_1.0.0_linux_amd64v3.tar.gz",
1617            "first target must render the env with ITS OWN Os seeded"
1618        );
1619        assert_eq!(
1620            assets["x86_64-apple-darwin"].asset_name, "myapp_1.0.0_darwin_amd64v2.tar.gz",
1621            "second target must not render with the first target's stale Os"
1622        );
1623        assert_eq!(
1624            assets["aarch64-unknown-linux-gnu"].asset_name,
1625            "myapp_1.0.0_linux_arm64.tar.gz"
1626        );
1627    }
1628
1629    /// A build env whose RUSTFLAGS cannot be rendered at config time (an
1630    /// undefined build-time-only variable) must NOT invent a variant: the
1631    /// derivation falls back to the baseline name and the snapshot
1632    /// emission cross-check remains the loud backstop.
1633    #[test]
1634    fn asset_names_fall_back_to_baseline_when_env_defeats_config_render() {
1635        let mut env = HashMap::new();
1636        env.insert(
1637            "x86_64-unknown-linux-gnu".to_string(),
1638            HashMap::from([(
1639                "RUSTFLAGS".to_string(),
1640                "{{ BuildTimeOnlyVar }}".to_string(),
1641            )]),
1642        );
1643        let crate_cfg = tuned_crate(env);
1644        let mut ctx = make_ctx();
1645        let assets = crate_archive_asset_names(&crate_cfg, &[], &mut ctx)
1646            .unwrap()
1647            .expect("derivation still resolves");
1648        assert_eq!(
1649            assets["x86_64-unknown-linux-gnu"].asset_name, "myapp_1.0.0_linux_amd64.tar.gz",
1650            "unrenderable env derives no variant (baseline name)"
1651        );
1652    }
1653
1654    /// Deriving the variant must not leak the build-env cascade into the
1655    /// caller's template env: `{{ .Env.KEY }}` seen by later renders is the
1656    /// value from BEFORE the derivation.
1657    #[test]
1658    fn variant_derivation_does_not_leak_build_env_into_ctx() {
1659        let mut env = HashMap::new();
1660        env.insert(
1661            "x86_64-unknown-linux-gnu".to_string(),
1662            HashMap::from([
1663                (
1664                    "RUSTFLAGS".to_string(),
1665                    "-Ctarget-cpu=x86-64-v2".to_string(),
1666                ),
1667                ("MY_BUILD_KEY".to_string(), "leaky".to_string()),
1668            ]),
1669        );
1670        let crate_cfg = tuned_crate(env);
1671        let mut ctx = make_ctx();
1672        ctx.template_vars_mut().set_env("RUSTFLAGS", "prior-value");
1673        let before_keys = ctx.template_vars().all_env().len();
1674        let assets = crate_archive_asset_names(&crate_cfg, &[], &mut ctx)
1675            .unwrap()
1676            .unwrap();
1677        assert_eq!(
1678            assets["x86_64-unknown-linux-gnu"].asset_name,
1679            "myapp_1.0.0_linux_amd64v2.tar.gz"
1680        );
1681        assert_eq!(
1682            ctx.template_vars().all_env().get("RUSTFLAGS").unwrap(),
1683            "prior-value",
1684            "the caller's prior env value must be restored after the cascade"
1685        );
1686        assert!(
1687            !ctx.template_vars().all_env().contains_key("MY_BUILD_KEY"),
1688            "cascade-only keys must be removed after derivation"
1689        );
1690        assert_eq!(ctx.template_vars().all_env().len(), before_keys);
1691    }
1692}