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 {
412        (release.gitea.as_ref()?, "https://gitea.com")
413    };
414    let owner = ctx.render_template(&repo_cfg.owner).ok()?;
415    let repo = ctx.render_template(&repo_cfg.name).ok()?;
416    if owner.is_empty() || repo.is_empty() {
417        return None;
418    }
419    Some((owner, repo, base.to_string()))
420}
421
422/// Pick the archive entry cargo-binstall should install from: the first entry
423/// whose default format is binstallable (tar.gz / zip / …),
424/// the primary archive is the one consumers fetch; auxiliary entries (e.g. a
425/// `tar.xz`/`tar.zst` `-extra` entry) are skipped.
426pub(crate) fn binstallable_archive(crate_cfg: &CrateConfig) -> Option<ArchiveConfig> {
427    let ArchivesConfig::Configs(configs) = &crate_cfg.archives else {
428        return None;
429    };
430    configs
431        .iter()
432        .find(|a| {
433            a.formats
434                .as_ref()
435                .and_then(|f| f.first())
436                .map(|fmt| binstall_pkg_fmt(fmt).is_some())
437                .unwrap_or(true)
438        })
439        .cloned()
440        .or_else(|| configs.first().cloned())
441}
442
443/// Resolve the full set of target triples binstall metadata must cover for
444/// `crate_cfg`: the union of each producing build's targets (a build's own
445/// `targets:` when set, else the global `default_targets`), de-duplicated.
446/// Routed through the build-synthesis SSOT
447/// ([`crate::build_plan::crate_target_list`]) so the derived override set equals
448/// the asset set the build stage actually releases — a library crate with no
449/// default binary (or a `binary: None` build with no matching `--bin`) compiles
450/// nothing and so contributes no targets.
451fn derive_target_list(crate_cfg: &CrateConfig, default_targets: &[String]) -> Vec<String> {
452    crate::build_plan::crate_target_list(crate_cfg, default_targets)
453}
454
455/// Render the crate's tag template with the version expressed as
456/// cargo-binstall's `{ version }` token. Stamps the [`VERSION_SENTINEL`] as the
457/// version, renders, then swaps the sentinel for `{ version }`.
458fn render_tag_with_version_token(crate_cfg: &CrateConfig, ctx: &mut Context) -> Result<String> {
459    // Prefer an explicit `release.tag` override; otherwise the crate's
460    // `tag_template` (defaulting to `v{{ Version }}` shape when unset).
461    let tag_template = crate_cfg
462        .release
463        .as_ref()
464        .and_then(|r| r.tag.clone())
465        .filter(|t| !t.is_empty())
466        .unwrap_or_else(|| crate_cfg.resolved_tag_template().to_string());
467
468    let prior = stamp_sentinel_version(ctx);
469    let rendered = ctx.render_template(&tag_template);
470    restore_version(ctx, prior);
471    let rendered = rendered
472        .with_context(|| format!("failed to render binstall tag template: {tag_template}"))?;
473    Ok(rendered.replace(VERSION_SENTINEL, "{ version }"))
474}
475
476/// Stamp the version-related template vars (`Version`, `RawVersion`, `Tag`) to
477/// the sentinel and return the prior values for [`restore_version`]. Tag is
478/// stamped too so a `name_template` referencing `{{ Tag }}` also picks up the
479/// sentinel.
480fn stamp_sentinel_version(ctx: &mut Context) -> Vec<(&'static str, Option<String>)> {
481    let prior: Vec<(&'static str, Option<String>)> = ["Version", "RawVersion", "Tag"]
482        .iter()
483        .map(|k| (*k, ctx.template_vars().get(k).cloned()))
484        .collect();
485
486    let vars = ctx.template_vars_mut();
487    vars.set("Version", VERSION_SENTINEL);
488    vars.set("RawVersion", VERSION_SENTINEL);
489    // A literal `v<sentinel>` keeps `{{ Tag }}`-based name templates aligned
490    // with the `v{ version }` tag the download URL targets.
491    vars.set("Tag", &format!("v{VERSION_SENTINEL}"));
492    prior
493}
494
495/// Restore the version vars captured by [`stamp_sentinel_version`].
496fn restore_version(ctx: &mut Context, prior: Vec<(&'static str, Option<String>)>) {
497    let vars = ctx.template_vars_mut();
498    for (key, value) in prior {
499        match value {
500            Some(v) => vars.set(key, &v),
501            None => {
502                vars.unset(key);
503            }
504        }
505    }
506}
507
508/// The default `archive.name_template` the archive stage uses for this crate:
509/// the multi-crate default when the archive stage's work list holds more than
510/// one crate, else the single-crate default. Resolves through the same
511/// selection the stage itself uses ([`crate::archive_selection`]) so the
512/// decision basis cannot drift from the stage's real work list — a
513/// workspace-only crate counts here exactly as it does there.
514fn default_archive_name_template(ctx: &Context) -> String {
515    let producing = crate::archive_selection::archive_producing_crates(
516        &ctx.config,
517        &ctx.artifacts,
518        &ctx.options.selected_crates,
519    );
520    if producing.len() > 1 {
521        crate::archive_name::DEFAULT_NAME_TEMPLATE_MULTI_CRATE.to_string()
522    } else {
523        crate::archive_name::DEFAULT_NAME_TEMPLATE.to_string()
524    }
525}
526
527/// The project-wide default archive format (`defaults.archives.formats[0]`,
528/// falling back to `tar.gz`). Used when an archive entry sets no `formats:`.
529fn global_default_archive_format(ctx: &Context) -> String {
530    ctx.config
531        .defaults
532        .as_ref()
533        .and_then(|d| d.archives.as_ref())
534        .and_then(|a| a.formats.as_ref())
535        .and_then(|f| f.first())
536        .cloned()
537        .unwrap_or_else(|| "tar.gz".to_string())
538}
539
540/// The archive format an entry produces for `target`: the first matching
541/// `format_overrides[]` entry's format (OS-prefix match, mirroring the archive
542/// stage), else the entry's own first `formats[]`, else `global_default`.
543fn archive_format_for_target(
544    archive: &ArchiveConfig,
545    target: &str,
546    global_default: &str,
547) -> String {
548    let (os, _arch) = map_target(target);
549    if let Some(overrides) = archive.format_overrides.as_ref() {
550        for ov in overrides {
551            if !ov.os.is_empty()
552                && os.starts_with(&ov.os)
553                && let Some(fmts) = ov.formats.as_ref()
554                && let Some(first) = fmts.first()
555            {
556                return first.clone();
557            }
558        }
559    }
560    archive
561        .formats
562        .as_ref()
563        .and_then(|f| f.first())
564        .cloned()
565        .unwrap_or_else(|| global_default.to_string())
566}
567
568#[cfg(test)]
569mod tests {
570    use std::collections::{BTreeMap, HashMap};
571
572    use super::*;
573    use crate::config::{
574        ArchiveConfig, ArchivesConfig, BinstallConfig, BinstallOverride, BuildConfig, Config,
575        FormatOverride, GitHubConfig, ReleaseConfig,
576    };
577    use crate::context::{Context, ContextOptions};
578
579    fn make_ctx() -> Context {
580        let config = Config {
581            project_name: "myapp".to_string(),
582            ..Default::default()
583        };
584        let mut ctx = Context::new(config, ContextOptions::default());
585        // Variant derivation consults the process env at every cargo flags
586        // tier; seal so an exported RUSTFLAGS cannot leak into fixtures.
587        ctx.set_env_source(crate::MapEnvSource::new());
588        ctx.template_vars_mut().set("Version", "1.0.0");
589        ctx.template_vars_mut().set("ProjectName", "myapp");
590        ctx
591    }
592
593    /// Drive [`generate_binstall_metadata`] against a temp-dir crate with the
594    /// given binstall config. The synthesized `CrateConfig` carries no archives
595    /// / release / builds, so auto-derivation is inert unless the test supplies
596    /// a richer crate via [`gen_with_crate`] — matching the bare
597    /// `(path, cfg, ctx)` call shape the surrounding tests use.
598    fn gen_meta(path: &str, cfg: &BinstallConfig, ctx: &mut Context, dry_run: bool) -> Result<()> {
599        let crate_cfg = CrateConfig {
600            name: "myapp".to_string(),
601            path: path.to_string(),
602            ..Default::default()
603        };
604        generate_binstall_metadata(&crate_cfg, cfg, &[], ctx, dry_run)
605    }
606
607    /// Like [`gen`] but with a caller-supplied `CrateConfig` (path is forced to
608    /// `path`) and an explicit `default_targets` list, for auto-derivation tests.
609    fn gen_with_crate(
610        path: &str,
611        mut crate_cfg: CrateConfig,
612        cfg: &BinstallConfig,
613        default_targets: &[String],
614        ctx: &mut Context,
615    ) -> Result<()> {
616        crate_cfg.path = path.to_string();
617        generate_binstall_metadata(&crate_cfg, cfg, default_targets, ctx, false)
618    }
619
620    #[test]
621    fn test_generate_binstall_metadata_inserts_section() {
622        let tmp = tempfile::tempdir().unwrap();
623        let cargo_toml = tmp.path().join("Cargo.toml");
624        std::fs::write(
625            &cargo_toml,
626            r#"[package]
627name = "myapp"
628version = "1.0.0"
629edition = "2024"
630"#,
631        )
632        .unwrap();
633
634        let binstall_cfg = BinstallConfig {
635            enabled: Some(true),
636            pkg_url: Some(
637                "https://github.com/myorg/myapp/releases/download/v{{ .Version }}/myapp-{{ .Version }}-{ target }.tar.gz"
638                    .to_string(),
639            ),
640            bin_dir: Some("{ bin }{ binary-ext }".to_string()),
641            pkg_fmt: Some("tgz".to_string()),
642            overrides: None,
643        };
644
645        let mut ctx = make_ctx();
646        gen_meta(tmp.path().to_str().unwrap(), &binstall_cfg, &mut ctx, false).unwrap();
647
648        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
649        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
650
651        let binstall = &doc["package"]["metadata"]["binstall"];
652        assert!(
653            binstall.as_table().is_some(),
654            "binstall section should exist as a table"
655        );
656        assert_eq!(binstall["pkg-fmt"].as_str().unwrap(), "tgz");
657        assert_eq!(
658            binstall["bin-dir"].as_str().unwrap(),
659            "{ bin }{ binary-ext }"
660        );
661        // The pkg-url should have the template variable rendered
662        let pkg_url = binstall["pkg-url"].as_str().unwrap();
663        assert!(
664            pkg_url.contains("1.0.0"),
665            "pkg-url should have Version rendered, got: {pkg_url}"
666        );
667    }
668
669    #[test]
670    fn test_generate_binstall_metadata_dry_run() {
671        let tmp = tempfile::tempdir().unwrap();
672        let cargo_toml = tmp.path().join("Cargo.toml");
673        let original = r#"[package]
674name = "myapp"
675version = "1.0.0"
676edition = "2024"
677"#;
678        std::fs::write(&cargo_toml, original).unwrap();
679
680        let binstall_cfg = BinstallConfig {
681            enabled: Some(true),
682            pkg_url: Some("https://example.com".to_string()),
683            bin_dir: None,
684            pkg_fmt: None,
685            overrides: None,
686        };
687
688        let mut ctx = make_ctx();
689        gen_meta(tmp.path().to_str().unwrap(), &binstall_cfg, &mut ctx, true).unwrap();
690
691        // File should be unchanged in dry-run mode
692        let content = std::fs::read_to_string(&cargo_toml).unwrap();
693        assert_eq!(content, original);
694    }
695
696    #[test]
697    fn test_generate_binstall_metadata_missing_cargo_toml_errors() {
698        let tmp = tempfile::tempdir().unwrap();
699        let binstall_cfg = BinstallConfig {
700            enabled: Some(true),
701            pkg_url: None,
702            bin_dir: None,
703            pkg_fmt: None,
704            overrides: None,
705        };
706
707        let mut ctx = make_ctx();
708        let result = gen_meta(tmp.path().to_str().unwrap(), &binstall_cfg, &mut ctx, false);
709        assert!(result.is_err());
710    }
711
712    #[test]
713    fn test_generate_binstall_metadata_emits_per_target_overrides() {
714        let tmp = tempfile::tempdir().unwrap();
715        let cargo_toml = tmp.path().join("Cargo.toml");
716        std::fs::write(
717            &cargo_toml,
718            r#"[package]
719name = "cfgd"
720version = "1.0.0"
721edition = "2024"
722"#,
723        )
724        .unwrap();
725
726        let mut overrides = BTreeMap::new();
727        overrides.insert(
728            "x86_64-unknown-linux-gnu".to_string(),
729            BinstallOverride {
730                pkg_url: Some(
731                    "https://github.com/myorg/cfgd/releases/download/v{{ .Version }}/cfgd-{{ .Version }}-linux-amd64.tar.gz"
732                        .to_string(),
733                ),
734                pkg_fmt: Some("tgz".to_string()),
735                bin_dir: Some("{ bin }{ binary-ext }".to_string()),
736            },
737        );
738        overrides.insert(
739            "aarch64-apple-darwin".to_string(),
740            BinstallOverride {
741                pkg_url: Some(
742                    "https://github.com/myorg/cfgd/releases/download/v{{ .Version }}/cfgd-{ version }-darwin-arm64.tar.gz"
743                        .to_string(),
744                ),
745                pkg_fmt: Some("tgz".to_string()),
746                bin_dir: None,
747            },
748        );
749
750        let binstall_cfg = BinstallConfig {
751            enabled: Some(true),
752            pkg_url: None,
753            bin_dir: None,
754            pkg_fmt: None,
755            overrides: Some(overrides),
756        };
757
758        let mut ctx = make_ctx();
759        gen_meta(tmp.path().to_str().unwrap(), &binstall_cfg, &mut ctx, false).unwrap();
760
761        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
762        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
763
764        let overrides_item = &doc["package"]["metadata"]["binstall"]["overrides"];
765        assert!(
766            overrides_item.as_table().is_some(),
767            "binstall.overrides should exist as a table"
768        );
769
770        // linux-amd64 (go-arch) entry: Version rendered, asset name intact.
771        let linux = &overrides_item["x86_64-unknown-linux-gnu"];
772        assert!(
773            linux.as_table().is_some(),
774            "override sub-table should be a real table"
775        );
776        let linux_url = linux["pkg-url"].as_str().unwrap();
777        assert!(
778            linux_url.contains("cfgd-1.0.0-linux-amd64.tar.gz"),
779            "linux pkg-url should be Version-rendered with go-arch asset name, got: {linux_url}"
780        );
781        assert!(
782            !linux_url.contains("{{ .Version }}"),
783            "anodize token should be rendered, got: {linux_url}"
784        );
785        assert_eq!(linux["pkg-fmt"].as_str().unwrap(), "tgz");
786        assert_eq!(linux["bin-dir"].as_str().unwrap(), "{ bin }{ binary-ext }");
787
788        // darwin-arm64 (go-arch) entry.
789        let darwin = &overrides_item["aarch64-apple-darwin"];
790        assert!(darwin.as_table().is_some());
791        let darwin_url = darwin["pkg-url"].as_str().unwrap();
792        // The leading v{{ .Version }} is an anodize token (rendered) while
793        // `{ version }` is cargo-binstall's own token and must survive intact.
794        assert!(
795            darwin_url.contains("/v1.0.0/cfgd-{ version }-darwin-arm64.tar.gz"),
796            "darwin pkg-url should render the anodize token but leave cargo-binstall's `{{ version }}` intact, got: {darwin_url}"
797        );
798
799        // Triple keys contain `-` and must render as proper headers.
800        assert!(
801            updated.contains("[package.metadata.binstall.overrides.x86_64-unknown-linux-gnu]"),
802            "override should render as a [...] header, got:\n{updated}"
803        );
804    }
805
806    #[test]
807    fn test_generate_binstall_metadata_preserves_user_authored_keys() {
808        let tmp = tempfile::tempdir().unwrap();
809        let cargo_toml = tmp.path().join("Cargo.toml");
810        // Seed a Cargo.toml whose binstall table already carries keys anodize
811        // does NOT model: cargo-binstall's `disabled-strategies` and the
812        // `[package.metadata.binstall.signing]` sub-table. The in-place merge
813        // must leave both untouched while (re)writing pkg-url / overrides.
814        std::fs::write(
815            &cargo_toml,
816            r#"[package]
817name = "myapp"
818version = "1.0.0"
819edition = "2024"
820
821[package.metadata.binstall]
822disabled-strategies = ["quick-install", "compile"]
823pkg-url = "https://old.example.com/stale"
824
825[package.metadata.binstall.signing]
826algorithm = "minisign"
827pubkey = "RWQABCDEF1234567890"
828"#,
829        )
830        .unwrap();
831
832        let mut overrides = BTreeMap::new();
833        overrides.insert(
834            "x86_64-unknown-linux-gnu".to_string(),
835            BinstallOverride {
836                pkg_url: Some(
837                    "https://github.com/myorg/myapp/releases/download/v{{ .Version }}/myapp-linux.tar.gz"
838                        .to_string(),
839                ),
840                pkg_fmt: Some("tgz".to_string()),
841                bin_dir: None,
842            },
843        );
844
845        let binstall_cfg = BinstallConfig {
846            enabled: Some(true),
847            pkg_url: Some(
848                "https://github.com/myorg/myapp/releases/download/v{{ .Version }}/myapp-{ target }.tar.gz"
849                    .to_string(),
850            ),
851            bin_dir: None,
852            pkg_fmt: None,
853            overrides: Some(overrides),
854        };
855
856        let mut ctx = make_ctx();
857        gen_meta(tmp.path().to_str().unwrap(), &binstall_cfg, &mut ctx, false).unwrap();
858
859        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
860        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
861        let binstall = &doc["package"]["metadata"]["binstall"];
862
863        // Unknown keys survive verbatim.
864        let strategies = binstall["disabled-strategies"].as_array().unwrap();
865        let strategy_vals: Vec<&str> = strategies.iter().filter_map(|v| v.as_str()).collect();
866        assert_eq!(
867            strategy_vals,
868            vec!["quick-install", "compile"],
869            "disabled-strategies must survive the merge verbatim"
870        );
871
872        let signing = &binstall["signing"];
873        assert!(
874            signing.as_table().is_some(),
875            "signing sub-table must survive the merge"
876        );
877        assert_eq!(signing["algorithm"].as_str().unwrap(), "minisign");
878        assert_eq!(signing["pubkey"].as_str().unwrap(), "RWQABCDEF1234567890");
879
880        // anodize-owned keys are (re)written: pkg-url rendered to the new value.
881        let pkg_url = binstall["pkg-url"].as_str().unwrap();
882        assert!(
883            pkg_url.contains("/v1.0.0/myapp-{ target }.tar.gz"),
884            "pkg-url should be rewritten with the rendered Version, got: {pkg_url}"
885        );
886        assert!(
887            !pkg_url.contains("old.example.com"),
888            "stale anodize-owned pkg-url should be replaced, got: {pkg_url}"
889        );
890
891        // overrides is anodize-owned and freshly written.
892        let linux = &binstall["overrides"]["x86_64-unknown-linux-gnu"];
893        assert_eq!(linux["pkg-fmt"].as_str().unwrap(), "tgz");
894        assert!(
895            linux["pkg-url"]
896                .as_str()
897                .unwrap()
898                .contains("/v1.0.0/myapp-linux.tar.gz")
899        );
900    }
901
902    #[test]
903    fn test_generate_binstall_metadata_clears_unset_owned_key_keeps_unknown() {
904        let tmp = tempfile::tempdir().unwrap();
905        let cargo_toml = tmp.path().join("Cargo.toml");
906        // pkg-url present plus an unknown sibling key. Config omits pkg_url, so
907        // the owned key must be cleared while the unknown key survives.
908        std::fs::write(
909            &cargo_toml,
910            r#"[package]
911name = "myapp"
912version = "1.0.0"
913edition = "2024"
914
915[package.metadata.binstall]
916disabled-strategies = ["compile"]
917pkg-url = "https://old.example.com/stale"
918"#,
919        )
920        .unwrap();
921
922        let binstall_cfg = BinstallConfig {
923            enabled: Some(true),
924            pkg_url: None,
925            bin_dir: None,
926            pkg_fmt: None,
927            overrides: None,
928        };
929
930        let mut ctx = make_ctx();
931        gen_meta(tmp.path().to_str().unwrap(), &binstall_cfg, &mut ctx, false).unwrap();
932
933        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
934        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
935        let binstall = &doc["package"]["metadata"]["binstall"];
936
937        assert!(
938            binstall.get("pkg-url").is_none(),
939            "unset owned key should be cleared, got:\n{updated}"
940        );
941        let strategies = binstall["disabled-strategies"].as_array().unwrap();
942        assert_eq!(strategies.len(), 1, "unknown key must survive clearing");
943        assert_eq!(strategies.get(0).unwrap().as_str().unwrap(), "compile");
944    }
945
946    #[test]
947    fn test_generate_binstall_metadata_merges_inline_table_preserving_unknown() {
948        let tmp = tempfile::tempdir().unwrap();
949        let cargo_toml = tmp.path().join("Cargo.toml");
950        // A user with INLINE binstall metadata: `as_table_mut()` would return
951        // None on this. The merge must convert it to a header table while
952        // preserving the unknown `disabled-strategies` key.
953        std::fs::write(
954            &cargo_toml,
955            r#"[package]
956name = "myapp"
957version = "1.0.0"
958edition = "2024"
959metadata.binstall = { pkg-url = "https://old.example.com/stale", disabled-strategies = ["compile"] }
960"#,
961        )
962        .unwrap();
963
964        let binstall_cfg = BinstallConfig {
965            enabled: Some(true),
966            pkg_url: Some(
967                "https://github.com/myorg/myapp/releases/download/v{{ .Version }}/myapp-{ target }.tar.gz"
968                    .to_string(),
969            ),
970            bin_dir: None,
971            pkg_fmt: None,
972            overrides: None,
973        };
974
975        let mut ctx = make_ctx();
976        gen_meta(tmp.path().to_str().unwrap(), &binstall_cfg, &mut ctx, false).unwrap();
977
978        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
979        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
980        let binstall = &doc["package"]["metadata"]["binstall"];
981
982        // pkg-url rewritten with the rendered Version.
983        let pkg_url = binstall["pkg-url"].as_str().unwrap();
984        assert!(
985            pkg_url.contains("/v1.0.0/myapp-{ target }.tar.gz")
986                && !pkg_url.contains("old.example.com"),
987            "inline pkg-url should be rewritten, got: {pkg_url}"
988        );
989        // Unknown key carried over from the inline table.
990        let strategies = binstall["disabled-strategies"].as_array().unwrap();
991        assert_eq!(strategies.len(), 1);
992        assert_eq!(strategies.get(0).unwrap().as_str().unwrap(), "compile");
993    }
994
995    // -----------------------------------------------------------------------
996    // Auto-derivation
997    // -----------------------------------------------------------------------
998
999    /// All six anodize triples, the matrix the auto-derivation must cover.
1000    fn six_targets() -> Vec<String> {
1001        vec![
1002            "x86_64-unknown-linux-gnu".to_string(),
1003            "aarch64-unknown-linux-gnu".to_string(),
1004            "x86_64-apple-darwin".to_string(),
1005            "aarch64-apple-darwin".to_string(),
1006            "x86_64-pc-windows-msvc".to_string(),
1007            "aarch64-pc-windows-msvc".to_string(),
1008        ]
1009    }
1010
1011    /// A crate mirroring anodize's binary crate: an explicit
1012    /// `name_template: "{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}"`,
1013    /// `formats: [tar.gz]` with a windows→zip override, and a GitHub release.
1014    fn anodize_like_crate() -> CrateConfig {
1015        let archive = ArchiveConfig {
1016            name_template: Some("{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}".to_string()),
1017            formats: Some(vec!["tar.gz".to_string()]),
1018            format_overrides: Some(vec![FormatOverride {
1019                os: "windows".to_string(),
1020                formats: Some(vec!["zip".to_string()]),
1021            }]),
1022            ..Default::default()
1023        };
1024        CrateConfig {
1025            name: "anodizer".to_string(),
1026            tag_template: Some("v{{ Version }}".to_string()),
1027            archives: ArchivesConfig::Configs(vec![archive]),
1028            release: Some(ReleaseConfig {
1029                github: Some(GitHubConfig {
1030                    owner: "tj-smith47".to_string(),
1031                    name: "anodizer".to_string(),
1032                    token: None,
1033                }),
1034                ..Default::default()
1035            }),
1036            ..Default::default()
1037        }
1038    }
1039
1040    /// The asset filename a binstall override resolves to, with the
1041    /// cargo-binstall `{ version }` token substituted back to the test version.
1042    fn override_asset(binstall: &toml_edit::Item, triple: &str, version: &str) -> String {
1043        let url = binstall["overrides"][triple]["pkg-url"].as_str().unwrap();
1044        let leaf = url.rsplit('/').next().unwrap();
1045        leaf.replace("{ version }", version)
1046    }
1047
1048    #[test]
1049    fn auto_derive_covers_all_six_triples_matching_archive_names() {
1050        let tmp = tempfile::tempdir().unwrap();
1051        let cargo_toml = tmp.path().join("Cargo.toml");
1052        std::fs::write(
1053            &cargo_toml,
1054            "[package]\nname = \"anodizer\"\nversion = \"1.0.0\"\nedition = \"2024\"\n",
1055        )
1056        .unwrap();
1057        // A binstallable crate is a binary crate; declare the `--bin` so the
1058        // build-synthesis gate the override set now routes through sees a
1059        // producing default build (otherwise a no-bin crate derives nothing).
1060        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
1061        std::fs::write(tmp.path().join("src/main.rs"), "fn main() {}\n").unwrap();
1062
1063        // enabled: true, NOTHING else — the whole point.
1064        let cfg = BinstallConfig {
1065            enabled: Some(true),
1066            ..Default::default()
1067        };
1068        let mut ctx = make_ctx();
1069        ctx.template_vars_mut().set("ProjectName", "anodizer");
1070        let targets = six_targets();
1071        gen_with_crate(
1072            tmp.path().to_str().unwrap(),
1073            anodize_like_crate(),
1074            &cfg,
1075            &targets,
1076            &mut ctx,
1077        )
1078        .unwrap();
1079
1080        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
1081        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
1082        let binstall = &doc["package"]["metadata"]["binstall"];
1083
1084        // Every triple got an override, and each override's asset name EQUALS
1085        // what the shared archive name-rendering produces for that target —
1086        // the byte-equality that kills the 404 class. Render the expectation
1087        // through the SAME core function the archive stage uses.
1088        let mut check_ctx = make_ctx();
1089        check_ctx.template_vars_mut().set("ProjectName", "anodizer");
1090        let name_tmpl = "{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}";
1091        for triple in &targets {
1092            let fmt = if triple.contains("windows") {
1093                "zip"
1094            } else {
1095                "tar.gz"
1096            };
1097            let expected = crate::archive_name::render_archive_asset_name(
1098                &mut check_ctx,
1099                name_tmpl,
1100                triple,
1101                fmt,
1102            )
1103            .unwrap();
1104            let actual = override_asset(binstall, triple, "1.0.0");
1105            assert_eq!(
1106                actual, expected,
1107                "override asset for {triple} must equal the archive stage's name"
1108            );
1109
1110            // pkg-fmt matches the format.
1111            let pkg_fmt = binstall["overrides"][triple]["pkg-fmt"].as_str().unwrap();
1112            assert_eq!(pkg_fmt, if fmt == "zip" { "zip" } else { "tgz" });
1113
1114            // cargo-binstall's `{ version }` token is present (resolves per
1115            // install), and the full download URL is well-formed.
1116            let url = binstall["overrides"][triple]["pkg-url"].as_str().unwrap();
1117            assert!(
1118                url.starts_with(
1119                    "https://github.com/tj-smith47/anodizer/releases/download/v{ version }/"
1120                ),
1121                "url should target the GitHub release download path with the binstall version token, got: {url}"
1122            );
1123            assert!(
1124                url.contains("{ version }"),
1125                "url must carry cargo-binstall's version token, got: {url}"
1126            );
1127        }
1128
1129        // Concrete spot-checks for the two endpoints.
1130        assert_eq!(
1131            override_asset(binstall, "x86_64-unknown-linux-gnu", "9.9.9"),
1132            "anodizer-9.9.9-linux-amd64.tar.gz"
1133        );
1134        assert_eq!(
1135            override_asset(binstall, "aarch64-pc-windows-msvc", "9.9.9"),
1136            "anodizer-9.9.9-windows-arm64.zip"
1137        );
1138
1139        // No stale top-level pkg-url leaks in.
1140        assert!(
1141            binstall.get("pkg-url").is_none(),
1142            "auto-derivation must not write a top-level pkg-url, got:\n{updated}"
1143        );
1144    }
1145
1146    #[test]
1147    fn auto_derive_matches_real_v091_release_assets() {
1148        // Real-world guard: anodize's own crate config (owner/repo/name_template/
1149        // tag_template from `.anodizer.yaml`) must auto-derive overrides whose
1150        // `pkg-url`s resolve, for a concrete version, to the EXACT asset names
1151        // the v0.9.1 GitHub release uploaded. A drift here is the "cargo binstall
1152        // 404 / source-compile" bug this whole path exists to prevent. The
1153        // expected strings are the literal v0.9.1 release assets, hand-written so
1154        // a refactor of the rendering can't move both sides in lockstep.
1155        let tmp = tempfile::tempdir().unwrap();
1156        let cargo_toml = tmp.path().join("Cargo.toml");
1157        std::fs::write(
1158            &cargo_toml,
1159            "[package]\nname = \"anodizer\"\nversion = \"0.9.1\"\nedition = \"2024\"\n",
1160        )
1161        .unwrap();
1162        // Declare the `--bin` so the build-synthesis gate sees a producing
1163        // default build (a no-bin crate derives no binstall targets).
1164        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
1165        std::fs::write(tmp.path().join("src/main.rs"), "fn main() {}\n").unwrap();
1166
1167        let cfg = BinstallConfig {
1168            enabled: Some(true),
1169            ..Default::default()
1170        };
1171        let mut ctx = make_ctx();
1172        ctx.template_vars_mut().set("ProjectName", "anodizer");
1173        gen_with_crate(
1174            tmp.path().to_str().unwrap(),
1175            anodize_like_crate(),
1176            &cfg,
1177            &six_targets(),
1178            &mut ctx,
1179        )
1180        .unwrap();
1181
1182        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
1183        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
1184        let binstall = &doc["package"]["metadata"]["binstall"];
1185
1186        // (triple, real v0.9.1 asset name, real v0.9.1 pkg-fmt)
1187        let expected: &[(&str, &str, &str)] = &[
1188            (
1189                "x86_64-unknown-linux-gnu",
1190                "anodizer-0.9.1-linux-amd64.tar.gz",
1191                "tgz",
1192            ),
1193            (
1194                "aarch64-unknown-linux-gnu",
1195                "anodizer-0.9.1-linux-arm64.tar.gz",
1196                "tgz",
1197            ),
1198            (
1199                "x86_64-apple-darwin",
1200                "anodizer-0.9.1-darwin-amd64.tar.gz",
1201                "tgz",
1202            ),
1203            (
1204                "aarch64-apple-darwin",
1205                "anodizer-0.9.1-darwin-arm64.tar.gz",
1206                "tgz",
1207            ),
1208            (
1209                "x86_64-pc-windows-msvc",
1210                "anodizer-0.9.1-windows-amd64.zip",
1211                "zip",
1212            ),
1213            (
1214                "aarch64-pc-windows-msvc",
1215                "anodizer-0.9.1-windows-arm64.zip",
1216                "zip",
1217            ),
1218        ];
1219
1220        for (triple, asset, fmt) in expected {
1221            let url = binstall["overrides"][*triple]["pkg-url"].as_str().unwrap();
1222            // The override carries cargo-binstall's `{ version }` token; resolved
1223            // for 0.9.1 it must equal the real release download URL byte-for-byte.
1224            let resolved = url.replace("{ version }", "0.9.1");
1225            let want =
1226                format!("https://github.com/tj-smith47/anodizer/releases/download/v0.9.1/{asset}");
1227            assert_eq!(
1228                resolved, want,
1229                "override for {triple} must resolve to the real v0.9.1 asset URL"
1230            );
1231            assert_eq!(
1232                binstall["overrides"][*triple]["pkg-fmt"].as_str().unwrap(),
1233                *fmt,
1234                "pkg-fmt for {triple} must match the real v0.9.1 asset format"
1235            );
1236        }
1237    }
1238
1239    #[test]
1240    fn user_pkg_url_suppresses_auto_derivation() {
1241        let tmp = tempfile::tempdir().unwrap();
1242        let cargo_toml = tmp.path().join("Cargo.toml");
1243        std::fs::write(
1244            &cargo_toml,
1245            "[package]\nname = \"anodizer\"\nversion = \"1.0.0\"\nedition = \"2024\"\n",
1246        )
1247        .unwrap();
1248
1249        let cfg = BinstallConfig {
1250            enabled: Some(true),
1251            pkg_url: Some("https://example.com/custom/anodizer-{ target }.tar.gz".to_string()),
1252            ..Default::default()
1253        };
1254        let mut ctx = make_ctx();
1255        ctx.template_vars_mut().set("ProjectName", "anodizer");
1256        gen_with_crate(
1257            tmp.path().to_str().unwrap(),
1258            anodize_like_crate(),
1259            &cfg,
1260            &six_targets(),
1261            &mut ctx,
1262        )
1263        .unwrap();
1264
1265        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
1266        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
1267        let binstall = &doc["package"]["metadata"]["binstall"];
1268
1269        // Manual pkg-url wins; NO auto-derived overrides table is emitted.
1270        assert_eq!(
1271            binstall["pkg-url"].as_str().unwrap(),
1272            "https://example.com/custom/anodizer-{ target }.tar.gz"
1273        );
1274        assert!(
1275            binstall.get("overrides").is_none(),
1276            "a user pkg_url must suppress auto-derived overrides, got:\n{updated}"
1277        );
1278    }
1279
1280    #[test]
1281    fn user_override_suppresses_auto_derivation() {
1282        let tmp = tempfile::tempdir().unwrap();
1283        let cargo_toml = tmp.path().join("Cargo.toml");
1284        std::fs::write(
1285            &cargo_toml,
1286            "[package]\nname = \"anodizer\"\nversion = \"1.0.0\"\nedition = \"2024\"\n",
1287        )
1288        .unwrap();
1289
1290        let mut user_overrides = BTreeMap::new();
1291        user_overrides.insert(
1292            "x86_64-unknown-linux-gnu".to_string(),
1293            BinstallOverride {
1294                pkg_url: Some("https://example.com/manual-linux.tar.gz".to_string()),
1295                pkg_fmt: Some("tgz".to_string()),
1296                bin_dir: None,
1297            },
1298        );
1299        let cfg = BinstallConfig {
1300            enabled: Some(true),
1301            overrides: Some(user_overrides),
1302            ..Default::default()
1303        };
1304        let mut ctx = make_ctx();
1305        ctx.template_vars_mut().set("ProjectName", "anodizer");
1306        gen_with_crate(
1307            tmp.path().to_str().unwrap(),
1308            anodize_like_crate(),
1309            &cfg,
1310            &six_targets(),
1311            &mut ctx,
1312        )
1313        .unwrap();
1314
1315        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
1316        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
1317        let overrides = &doc["package"]["metadata"]["binstall"]["overrides"];
1318
1319        // ONLY the user's single override is emitted — auto-derivation does not
1320        // add the other five triples.
1321        assert_eq!(
1322            overrides["x86_64-unknown-linux-gnu"]["pkg-url"]
1323                .as_str()
1324                .unwrap(),
1325            "https://example.com/manual-linux.tar.gz"
1326        );
1327        assert!(
1328            overrides.get("aarch64-apple-darwin").is_none(),
1329            "supplying one override must suppress auto-derivation of the rest, got:\n{updated}"
1330        );
1331    }
1332
1333    #[test]
1334    fn auto_derive_uses_default_template_when_unset() {
1335        // A crate with binstall enabled but NO explicit archive name_template
1336        // must derive against the canonical default (single-crate) template.
1337        let tmp = tempfile::tempdir().unwrap();
1338        let cargo_toml = tmp.path().join("Cargo.toml");
1339        std::fs::write(
1340            &cargo_toml,
1341            "[package]\nname = \"myapp\"\nversion = \"1.0.0\"\nedition = \"2024\"\n",
1342        )
1343        .unwrap();
1344
1345        // Declare the `--bin` so the build-synthesis gate sees a producing
1346        // default build (a no-bin crate derives no binstall targets).
1347        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
1348        std::fs::write(tmp.path().join("src/main.rs"), "fn main() {}\n").unwrap();
1349
1350        let mut crate_cfg = anodize_like_crate();
1351        crate_cfg.name = "myapp".to_string();
1352        // Archive with formats but no name_template.
1353        crate_cfg.archives = ArchivesConfig::Configs(vec![ArchiveConfig {
1354            formats: Some(vec!["tar.gz".to_string()]),
1355            ..Default::default()
1356        }]);
1357
1358        let cfg = BinstallConfig {
1359            enabled: Some(true),
1360            ..Default::default()
1361        };
1362        let mut ctx = make_ctx();
1363        gen_with_crate(
1364            tmp.path().to_str().unwrap(),
1365            crate_cfg,
1366            &cfg,
1367            &["x86_64-unknown-linux-gnu".to_string()],
1368            &mut ctx,
1369        )
1370        .unwrap();
1371
1372        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
1373        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
1374        let binstall = &doc["package"]["metadata"]["binstall"];
1375        // Default template → `myapp_1.0.0_linux_amd64.tar.gz`.
1376        assert_eq!(
1377            override_asset(binstall, "x86_64-unknown-linux-gnu", "1.0.0"),
1378            "myapp_1.0.0_linux_amd64.tar.gz"
1379        );
1380    }
1381
1382    #[test]
1383    fn auto_derive_noops_without_release_repo() {
1384        // No release repo → no download URL can be built → no overrides.
1385        let tmp = tempfile::tempdir().unwrap();
1386        let cargo_toml = tmp.path().join("Cargo.toml");
1387        std::fs::write(
1388            &cargo_toml,
1389            "[package]\nname = \"myapp\"\nversion = \"1.0.0\"\nedition = \"2024\"\n",
1390        )
1391        .unwrap();
1392
1393        let mut crate_cfg = anodize_like_crate();
1394        crate_cfg.name = "myapp".to_string();
1395        crate_cfg.release = None;
1396
1397        let cfg = BinstallConfig {
1398            enabled: Some(true),
1399            ..Default::default()
1400        };
1401        let mut ctx = make_ctx();
1402        gen_with_crate(
1403            tmp.path().to_str().unwrap(),
1404            crate_cfg,
1405            &cfg,
1406            &six_targets(),
1407            &mut ctx,
1408        )
1409        .unwrap();
1410
1411        let updated = std::fs::read_to_string(&cargo_toml).unwrap();
1412        let doc = updated.parse::<toml_edit::DocumentMut>().unwrap();
1413        let binstall = &doc["package"]["metadata"]["binstall"];
1414        assert!(
1415            binstall.get("overrides").is_none(),
1416            "no release repo should mean no auto-derived overrides, got:\n{updated}"
1417        );
1418    }
1419
1420    // --- normalize_binstall_to_table --------------------------------------
1421
1422    #[test]
1423    fn normalize_inserts_empty_table_when_binstall_absent() {
1424        let mut metadata = toml_edit::Table::new();
1425        normalize_binstall_to_table(&mut metadata).unwrap();
1426        let binstall = metadata.get("binstall").expect("binstall inserted");
1427        assert!(binstall.is_table(), "must be a header table");
1428        assert!(
1429            binstall.as_table().unwrap().is_empty(),
1430            "freshly-inserted binstall table must be empty"
1431        );
1432    }
1433
1434    #[test]
1435    fn normalize_leaves_existing_header_table_untouched() {
1436        let mut metadata = toml_edit::Table::new();
1437        let mut existing = toml_edit::Table::new();
1438        existing.insert("pkg-url", toml_edit::value("https://example/x"));
1439        metadata.insert("binstall", toml_edit::Item::Table(existing));
1440        normalize_binstall_to_table(&mut metadata).unwrap();
1441        assert_eq!(
1442            metadata["binstall"]["pkg-url"].as_str(),
1443            Some("https://example/x"),
1444            "an existing header table must be preserved verbatim"
1445        );
1446    }
1447
1448    #[test]
1449    fn normalize_converts_inline_table_preserving_keys() {
1450        // `binstall = { pkg-url = "…", custom = "keep" }` → header table with
1451        // every key carried over (including the user-authored `custom`).
1452        let doc: toml_edit::DocumentMut = r#"[package.metadata]
1453binstall = { pkg-url = "https://example/x", custom = "keep" }
1454"#
1455        .parse()
1456        .unwrap();
1457        let mut metadata = doc["package"]["metadata"].as_table().unwrap().clone();
1458        normalize_binstall_to_table(&mut metadata).unwrap();
1459        let binstall = metadata["binstall"].as_table().expect("now a header table");
1460        assert_eq!(binstall["pkg-url"].as_str(), Some("https://example/x"));
1461        assert_eq!(
1462            binstall["custom"].as_str(),
1463            Some("keep"),
1464            "user-authored inline keys must survive the conversion"
1465        );
1466    }
1467
1468    #[test]
1469    fn normalize_errors_on_non_table_binstall() {
1470        // A scalar `binstall = "oops"` is neither a table nor an inline table.
1471        let mut metadata = toml_edit::Table::new();
1472        metadata.insert("binstall", toml_edit::value("oops"));
1473        assert!(
1474            normalize_binstall_to_table(&mut metadata).is_err(),
1475            "a malformed scalar binstall must error, not silently pass"
1476        );
1477    }
1478
1479    // --- set_or_remove_str ------------------------------------------------
1480
1481    #[test]
1482    fn set_or_remove_str_sets_and_removes() {
1483        let mut table = toml_edit::Table::new();
1484        set_or_remove_str(&mut table, "k", Some("v"));
1485        assert_eq!(table["k"].as_str(), Some("v"));
1486        // A `None` value removes a now-unset key while leaving siblings intact.
1487        table.insert("sibling", toml_edit::value("keep"));
1488        set_or_remove_str(&mut table, "k", None);
1489        assert!(table.get("k").is_none(), "None must remove the key");
1490        assert_eq!(
1491            table["sibling"].as_str(),
1492            Some("keep"),
1493            "removing one key must not disturb siblings"
1494        );
1495    }
1496
1497    // --- render_overrides / render_overrides_map --------------------------
1498
1499    #[test]
1500    fn render_overrides_none_when_unset_or_empty() {
1501        let ctx = make_ctx();
1502        let cfg = BinstallConfig {
1503            overrides: None,
1504            ..Default::default()
1505        };
1506        assert!(
1507            render_overrides(&cfg, &ctx).unwrap().is_none(),
1508            "no overrides configured → None"
1509        );
1510        let cfg_empty = BinstallConfig {
1511            overrides: Some(BTreeMap::new()),
1512            ..Default::default()
1513        };
1514        assert!(
1515            render_overrides(&cfg_empty, &ctx).unwrap().is_none(),
1516            "an empty overrides map → None"
1517        );
1518    }
1519
1520    #[test]
1521    fn render_overrides_map_empty_is_none() {
1522        let ctx = make_ctx();
1523        let empty: BTreeMap<String, BinstallOverride> = BTreeMap::new();
1524        assert!(
1525            render_overrides_map(&empty, &ctx).unwrap().is_none(),
1526            "an empty map renders to None, never an empty table"
1527        );
1528    }
1529
1530    // --- variant-aware config-time derivation ------------------------------
1531
1532    /// A crate whose builds carry per-target env, against the DEFAULT archive
1533    /// name template, so the derived names exercise the `Amd64` suffix clause.
1534    fn tuned_crate(env: HashMap<String, HashMap<String, String>>) -> CrateConfig {
1535        CrateConfig {
1536            name: "myapp".to_string(),
1537            builds: Some(vec![BuildConfig {
1538                binary: Some("myapp".to_string()),
1539                targets: Some(vec![
1540                    "x86_64-unknown-linux-gnu".to_string(),
1541                    "aarch64-unknown-linux-gnu".to_string(),
1542                    "x86_64-apple-darwin".to_string(),
1543                ]),
1544                env: Some(env),
1545                ..Default::default()
1546            }]),
1547            archives: ArchivesConfig::Configs(vec![ArchiveConfig {
1548                formats: Some(vec!["tar.gz".to_string()]),
1549                ..Default::default()
1550            }]),
1551            ..Default::default()
1552        }
1553    }
1554
1555    /// A v3-tuned build (`RUSTFLAGS -Ctarget-cpu=x86-64-v3` in the per-target
1556    /// build env) must derive the SAME `amd64v3` asset name the archive stage
1557    /// uploads for that group — with zero manual overrides. Targets without
1558    /// the tuning env (the darwin amd64 build here) keep the baseline
1559    /// suffix-free name, proving the variant is keyed per target, not
1560    /// per crate.
1561    #[test]
1562    fn asset_names_carry_config_declared_amd64_variant() {
1563        let mut env = HashMap::new();
1564        env.insert(
1565            "x86_64-unknown-linux-gnu".to_string(),
1566            HashMap::from([(
1567                "RUSTFLAGS".to_string(),
1568                "-Ctarget-cpu=x86-64-v3".to_string(),
1569            )]),
1570        );
1571        let crate_cfg = tuned_crate(env);
1572        let mut ctx = make_ctx();
1573        let assets = crate_archive_asset_names(&crate_cfg, &[], &mut ctx)
1574            .unwrap()
1575            .expect("binstallable archive with targets derives names");
1576        assert_eq!(
1577            assets["x86_64-unknown-linux-gnu"].asset_name, "myapp_1.0.0_linux_amd64v3.tar.gz",
1578            "tuned target must carry the micro-arch suffix"
1579        );
1580        assert_eq!(
1581            assets["aarch64-unknown-linux-gnu"].asset_name,
1582            "myapp_1.0.0_linux_arm64.tar.gz"
1583        );
1584        assert_eq!(
1585            assets["x86_64-apple-darwin"].asset_name, "myapp_1.0.0_darwin_amd64.tar.gz",
1586            "untuned amd64 target stays on the baseline name"
1587        );
1588    }
1589
1590    /// A per-target RUSTFLAGS templated on the planner-seeded `Os` var must
1591    /// derive the right level for EVERY target of the loop. The regression
1592    /// shape: without per-target seeding inside the projection, target 1
1593    /// (linux) rendered with no `Os` at all (raw fallback → baseline) and
1594    /// target 2 (darwin) rendered with target 1's STALE `Os` (silently the
1595    /// wrong target's level).
1596    #[test]
1597    fn os_templated_tuning_env_derives_per_target_levels_across_the_loop() {
1598        let mut env = HashMap::new();
1599        env.insert(
1600            "x86_64-*".to_string(),
1601            HashMap::from([(
1602                "RUSTFLAGS".to_string(),
1603                "{% if Os == \"darwin\" %}-Ctarget-cpu=x86-64-v2\
1604                 {% else %}-Ctarget-cpu=x86-64-v3{% endif %}"
1605                    .to_string(),
1606            )]),
1607        );
1608        let crate_cfg = tuned_crate(env);
1609        let mut ctx = make_ctx();
1610        let assets = crate_archive_asset_names(&crate_cfg, &[], &mut ctx)
1611            .unwrap()
1612            .expect("binstallable archive with targets derives names");
1613        assert_eq!(
1614            assets["x86_64-unknown-linux-gnu"].asset_name, "myapp_1.0.0_linux_amd64v3.tar.gz",
1615            "first target must render the env with ITS OWN Os seeded"
1616        );
1617        assert_eq!(
1618            assets["x86_64-apple-darwin"].asset_name, "myapp_1.0.0_darwin_amd64v2.tar.gz",
1619            "second target must not render with the first target's stale Os"
1620        );
1621        assert_eq!(
1622            assets["aarch64-unknown-linux-gnu"].asset_name,
1623            "myapp_1.0.0_linux_arm64.tar.gz"
1624        );
1625    }
1626
1627    /// A build env whose RUSTFLAGS cannot be rendered at config time (an
1628    /// undefined build-time-only variable) must NOT invent a variant: the
1629    /// derivation falls back to the baseline name and the snapshot
1630    /// emission cross-check remains the loud backstop.
1631    #[test]
1632    fn asset_names_fall_back_to_baseline_when_env_defeats_config_render() {
1633        let mut env = HashMap::new();
1634        env.insert(
1635            "x86_64-unknown-linux-gnu".to_string(),
1636            HashMap::from([(
1637                "RUSTFLAGS".to_string(),
1638                "{{ BuildTimeOnlyVar }}".to_string(),
1639            )]),
1640        );
1641        let crate_cfg = tuned_crate(env);
1642        let mut ctx = make_ctx();
1643        let assets = crate_archive_asset_names(&crate_cfg, &[], &mut ctx)
1644            .unwrap()
1645            .expect("derivation still resolves");
1646        assert_eq!(
1647            assets["x86_64-unknown-linux-gnu"].asset_name, "myapp_1.0.0_linux_amd64.tar.gz",
1648            "unrenderable env derives no variant (baseline name)"
1649        );
1650    }
1651
1652    /// Deriving the variant must not leak the build-env cascade into the
1653    /// caller's template env: `{{ .Env.KEY }}` seen by later renders is the
1654    /// value from BEFORE the derivation.
1655    #[test]
1656    fn variant_derivation_does_not_leak_build_env_into_ctx() {
1657        let mut env = HashMap::new();
1658        env.insert(
1659            "x86_64-unknown-linux-gnu".to_string(),
1660            HashMap::from([
1661                (
1662                    "RUSTFLAGS".to_string(),
1663                    "-Ctarget-cpu=x86-64-v2".to_string(),
1664                ),
1665                ("MY_BUILD_KEY".to_string(), "leaky".to_string()),
1666            ]),
1667        );
1668        let crate_cfg = tuned_crate(env);
1669        let mut ctx = make_ctx();
1670        ctx.template_vars_mut().set_env("RUSTFLAGS", "prior-value");
1671        let before_keys = ctx.template_vars().all_env().len();
1672        let assets = crate_archive_asset_names(&crate_cfg, &[], &mut ctx)
1673            .unwrap()
1674            .unwrap();
1675        assert_eq!(
1676            assets["x86_64-unknown-linux-gnu"].asset_name,
1677            "myapp_1.0.0_linux_amd64v2.tar.gz"
1678        );
1679        assert_eq!(
1680            ctx.template_vars().all_env().get("RUSTFLAGS").unwrap(),
1681            "prior-value",
1682            "the caller's prior env value must be restored after the cascade"
1683        );
1684        assert!(
1685            !ctx.template_vars().all_env().contains_key("MY_BUILD_KEY"),
1686            "cascade-only keys must be removed after derivation"
1687        );
1688        assert_eq!(ctx.template_vars().all_env().len(), before_keys);
1689    }
1690}