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