Skip to main content

anodizer_stage_nsis/
lib.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::PathBuf;
4use std::process::Command;
5
6use anyhow::{Context as _, Result};
7
8use anodizer_core::artifact::{Artifact, ArtifactKind};
9use anodizer_core::context::Context;
10use anodizer_core::stage::Stage;
11
12// ---------------------------------------------------------------------------
13// Default NSIS script
14// ---------------------------------------------------------------------------
15
16/// Generate a default `.nsi` script template using Tera syntax.
17///
18/// Uses `{{ ProjectName }}`, `{{ NsisOutputFile }}`, `{{ ProgramFiles }}`,
19/// `{{ NsisBinaryPath }}`, and `{{ NsisBinaryName }}`. `NsisOutputFile` is
20/// the absolute path makensis writes the installer to (equal to the recorded
21/// artifact path); makensis resolves a relative `OutFile` against the script's
22/// directory, which is an ephemeral staging dir, so it must be absolute.
23/// `ProgramFiles` resolves to `$PROGRAMFILES64` on 64-bit targets and
24/// `$PROGRAMFILES` on 32-bit targets, so the installer lands in the correct
25/// directory on all Windows variants.
26pub fn default_nsi_script() -> &'static str {
27    r#"!include "MUI2.nsh"
28Name "{{ ProjectName }}"
29OutFile "{{ NsisOutputFile }}"
30InstallDir "{{ ProgramFiles }}\{{ ProjectName }}"
31RequestExecutionLevel admin
32!insertmacro MUI_PAGE_DIRECTORY
33!insertmacro MUI_PAGE_INSTFILES
34!insertmacro MUI_LANGUAGE "English"
35Section "Install"
36    SetOutPath "$INSTDIR"
37    File "{{ NsisBinaryPath }}"
38    CreateShortCut "$DESKTOP\{{ ProjectName }}.lnk" "$INSTDIR\{{ NsisBinaryName }}"
39    WriteUninstaller "$INSTDIR\uninstall.exe"
40SectionEnd
41Section "Uninstall"
42    Delete "$INSTDIR\{{ NsisBinaryName }}"
43    Delete "$DESKTOP\{{ ProjectName }}.lnk"
44    Delete "$INSTDIR\uninstall.exe"
45    RMDir "$INSTDIR"
46SectionEnd
47"#
48}
49
50// ---------------------------------------------------------------------------
51// makensis command construction
52// ---------------------------------------------------------------------------
53
54/// Build the `makensis` CLI arguments.
55///
56/// - `script_path`: path to the `.nsi` script file
57pub fn nsis_command(script_path: &str) -> Vec<String> {
58    vec!["makensis".to_string(), script_path.to_string()]
59}
60
61/// Resolve the installer output path to an absolute, cwd-independent path.
62///
63/// makensis is invoked with the rendered `.nsi` script, and it chdir's to the
64/// script's directory — an ephemeral staging tempdir — before resolving a
65/// relative `OutFile`. The recorded `Artifact.path` is later relativized to the
66/// process cwd by the registry (for deterministic `artifacts.json`), so the
67/// OutFile makensis writes to must be the absolute path that resolves to that
68/// same cwd-relative location. `canonicalize` is tried first (it resolves
69/// symlinks and `.` components) but fails pre-build because the file does not
70/// exist yet, so the cwd-join branch is what fires for a relative input.
71fn absolutize_output_path(path: PathBuf) -> PathBuf {
72    std::fs::canonicalize(&path).unwrap_or_else(|_| {
73        if path.is_absolute() {
74            path
75        } else {
76            std::env::current_dir()
77                .map(|c| c.join(&path))
78                .unwrap_or(path)
79        }
80    })
81}
82
83// ---------------------------------------------------------------------------
84// NsisStage
85// ---------------------------------------------------------------------------
86
87pub struct NsisStage;
88
89/// Parse Os and Arch from a Rust target triple using the shared mapping.
90fn os_arch_from_target(target: Option<&str>) -> (String, String) {
91    anodizer_core::target::os_arch_with_default(target, "windows")
92}
93
94/// Map a Go/Rust-style architecture identifier to the NSIS-native name.
95///
96/// Recognised values:
97/// `x86` for 32-bit, `x64` for 64-bit AMD, `arm64` for ARM 64-bit.
98pub(crate) fn map_arch_to_nsis(arch: &str) -> &str {
99    match arch {
100        "amd64" | "x86_64" => "x64",
101        "386" | "i386" | "i586" | "i686" | "x86" => "x86",
102        "arm64" | "aarch64" => "arm64",
103        other => other,
104    }
105}
106
107/// Return the correct NSIS `$PROGRAMFILESxx` constant for the given arch.
108///
109/// 64-bit targets use `$PROGRAMFILES64`; all others use `$PROGRAMFILES`.
110/// This prevents installers from landing in the WOW6432-redirected path
111/// (`Program Files (x86)`) on 64-bit Windows.
112pub(crate) fn program_files_for_arch(nsis_arch: &str) -> &str {
113    if nsis_arch == "x64" || nsis_arch == "arm64" {
114        "$PROGRAMFILES64"
115    } else {
116        "$PROGRAMFILES"
117    }
118}
119
120/// Default output filename template.
121///
122/// `Arch` here is the NSIS-native arch (`x86`, `x64`, `arm64`) injected
123/// per-target before the name is rendered.
124const DEFAULT_NAME_TEMPLATE: &str = "{{ ProjectName }}_{{ Arch }}_setup";
125
126impl Stage for NsisStage {
127    fn name(&self) -> &str {
128        "nsis"
129    }
130
131    fn run(&self, ctx: &mut Context) -> Result<()> {
132        let log = ctx.logger("nsis");
133        let selected = ctx.options.selected_crates.clone();
134        let dry_run = ctx.options.dry_run;
135        let dist = ctx.config.dist.clone();
136
137        // Collect crates that have NSIS config
138        let crates: Vec<_> = ctx
139            .config
140            .crates
141            .iter()
142            .filter(|c| selected.is_empty() || selected.contains(&c.name))
143            .filter(|c| c.nsis.is_some())
144            .cloned()
145            .collect();
146
147        if crates.is_empty() {
148            return Ok(());
149        }
150
151        // In workspace per-crate mode the same pipeline run produces an NSIS
152        // installer for each crate. Rebinding `ProjectName` to the current
153        // crate's name (mirroring the archive stage) keeps default name
154        // templates like `{{ ProjectName }}_{{ Arch }}_setup` distinct per crate
155        // so two crates' installers don't render the same filename and clobber
156        // each other. Restored after the loop.
157        let multi_crate = crates.len() > 1;
158        let original_project_name = ctx
159            .template_vars()
160            .get("ProjectName")
161            .cloned()
162            .unwrap_or_else(|| ctx.config.project_name.clone());
163
164        let mut new_artifacts: Vec<Artifact> = Vec::new();
165        let mut archives_to_remove: Vec<PathBuf> = Vec::new();
166
167        // Capture the loop result rather than `?`-ing inside it: a per-crate
168        // failure must still restore the rebound `ProjectName` below before
169        // propagating, so the workspace value never leaks past this stage.
170        let loop_result: Result<()> = (|| {
171            for krate in &crates {
172                let Some(nsis_configs) = krate.nsis.as_ref() else {
173                    continue;
174                };
175                if multi_crate {
176                    ctx.template_vars_mut().set("ProjectName", &krate.name);
177                }
178
179                // Collect Windows binary artifacts for this crate
180                let windows_binaries: Vec<_> = ctx
181                    .artifacts
182                    .by_kind_and_crate(ArtifactKind::Binary, &krate.name)
183                    .into_iter()
184                    .filter(|b| {
185                        b.target
186                            .as_deref()
187                            .map(anodizer_core::target::is_windows)
188                            .unwrap_or(false)
189                    })
190                    .cloned()
191                    .collect();
192
193                for nsis_cfg in nsis_configs {
194                    let nsis_id_for_log = nsis_cfg.id.as_deref().unwrap_or("default").to_string();
195
196                    // `nsis.if`: template-conditional skip (opt-in).
197                    // Render error => hard bail (W1 avoidance).
198                    let proceed = anodizer_core::config::evaluate_if_condition(
199                        nsis_cfg.if_condition.as_deref(),
200                        &format!(
201                            "nsis config '{}' for crate '{}'",
202                            nsis_id_for_log, krate.name
203                        ),
204                        |t| ctx.render_template(t),
205                    )?;
206                    if !proceed {
207                        log.status(&format!(
208                        "skipped nsis config '{}' for crate {} — `if` condition evaluated falsy",
209                        nsis_id_for_log, krate.name
210                    ));
211                        continue;
212                    }
213
214                    // Skip configs marked skip:
215                    if let Some(ref d) = nsis_cfg.skip {
216                        let off = d
217                            .try_evaluates_to_true(|s| ctx.render_template(s))
218                            .with_context(|| {
219                                format!("nsis: render skip template for crate {}", krate.name)
220                            })?;
221                        if off {
222                            log.status(&format!("NSIS config skipped for crate {}", krate.name));
223                            continue;
224                        }
225                    }
226
227                    // Filter by build IDs if specified
228                    let mut filtered = windows_binaries.clone();
229                    if let Some(ref filter_ids) = nsis_cfg.ids
230                        && !filter_ids.is_empty()
231                    {
232                        filtered.retain(|b| {
233                            b.metadata
234                                .get("id")
235                                .map(|id| filter_ids.contains(id))
236                                .unwrap_or(false)
237                                || b.metadata
238                                    .get("name")
239                                    .map(|n| filter_ids.contains(n))
240                                    .unwrap_or(false)
241                        });
242                    }
243
244                    // `amd64_variant` filter.
245                    // amd64-variant filtering:
246                    // only constrains `amd64` artifacts. Non-amd64 always passes.
247                    // Unset `amd64_variant` metadata is treated as `v1`.
248                    if let Some(ref want) = nsis_cfg.amd64_variant {
249                        filtered.retain(|b| {
250                            let target = b.target.as_deref().unwrap_or("");
251                            let (_, arch) = anodizer_core::target::map_target(target);
252                            if arch != "amd64" {
253                                return true;
254                            }
255                            b.metadata
256                                .get("amd64_variant")
257                                .map(String::as_str)
258                                .unwrap_or("v1")
259                                == want
260                        });
261                    }
262
263                    // Warn and skip if no Windows binaries found
264                    if filtered.is_empty() && windows_binaries.is_empty() {
265                        log.skip_line(
266                            ctx.options.show_skipped,
267                            &format!(
268                                "skipped NSIS generation for crate '{}' — no Windows binary \
269                         artifacts found (expected binaries targeting windows)",
270                                krate.name
271                            ),
272                        );
273                        continue;
274                    }
275                    if filtered.is_empty() {
276                        log.warn(&format!(
277                            "skipped nsis for crate '{}' — ids filter {:?} matched no binaries",
278                            krate.name, nsis_cfg.ids
279                        ));
280                        continue;
281                    }
282
283                    let effective_binaries: Vec<(Option<String>, PathBuf)> = filtered
284                        .iter()
285                        .map(|b| (b.target.clone(), b.path.clone()))
286                        .collect();
287
288                    // Validate extra_files shape up-front so misconfiguration fails
289                    // before any subprocess spawn and surfaces in dry-run too.
290                    // The canonical resolver bails when a constant name_template is
291                    // paired with a multi-match glob (which would silently
292                    // overwrite every match to the same dst name). The resolved set
293                    // is recomputed at copy time below.
294                    if let Some(extra_files) = &nsis_cfg.extra_files {
295                        anodizer_core::extrafiles::resolve(extra_files, &log)
296                            .context("nsis: validate extra_files")?;
297                    }
298
299                    // Check that makensis is available once per config (not per binary)
300                    if !dry_run && !anodizer_core::util::find_binary("makensis") {
301                        anyhow::bail!(
302                            "makensis not found on PATH; install NSIS to create Windows installers"
303                        );
304                    }
305
306                    for (target, binary_path) in &effective_binaries {
307                        // Derive Os/Arch from the target triple for template rendering
308                        let (os, arch) = os_arch_from_target(target.as_deref());
309
310                        // Set Os/Arch/Target in the global vars so extra_files,
311                        // templated_extra_files, and mod_timestamp can reference them.
312                        ctx.template_vars_mut().set("Os", &os);
313                        ctx.template_vars_mut().set("Arch", &arch);
314                        ctx.template_vars_mut()
315                            .set("Target", target.as_deref().unwrap_or(""));
316
317                        // Build a one-shot render context with NSIS-native vars so
318                        // user scripts can use these names without polluting
319                        // the global template var table.
320                        let nsis_arch = map_arch_to_nsis(&arch);
321                        let program_files = program_files_for_arch(nsis_arch);
322
323                        let binary_name_raw = binary_path
324                            .file_name()
325                            .and_then(|n| n.to_str())
326                            .unwrap_or(&krate.name);
327
328                        let binary_val = binary_name_raw.to_string();
329
330                        // Determine output filename using the one-shot vars so `Arch`
331                        // inside `name` sees NSIS-native values (`x64`, `x86`, `arm64`).
332                        let name_template =
333                            nsis_cfg.name.as_deref().unwrap_or(DEFAULT_NAME_TEMPLATE);
334
335                        let mut name_vars = ctx.template_vars().clone();
336                        name_vars.set("Arch", nsis_arch);
337                        name_vars.set("ProgramFiles", program_files);
338                        name_vars.set("Binary", &binary_val);
339
340                        // Render the name first so it can be re-injected as `Name`
341                        // for the script context.
342                        let rendered_name =
343                            anodizer_core::template::render(name_template, &name_vars)
344                                .with_context(|| {
345                                    format!(
346                                        "nsis: render name template for crate {} target {:?}",
347                                        krate.name, target
348                                    )
349                                })?;
350
351                        name_vars.set("Name", &rendered_name);
352
353                        // The recorded artifact path and the `OutFile` makensis is
354                        // told to write must end in `.exe`. The default name template
355                        // is extension-less (mirroring how dmg/pkg append `.dmg`/`.pkg`
356                        // after rendering); append `.exe` here unless the user's custom
357                        // `name` already supplies it (case-insensitive, no double-append).
358                        let exe_filename = if rendered_name.to_ascii_lowercase().ends_with(".exe") {
359                            rendered_name
360                        } else {
361                            format!("{rendered_name}.exe")
362                        };
363
364                        // Output goes in dist/windows/
365                        let output_dir = dist.join("windows");
366                        let exe_path = output_dir.join(&exe_filename);
367
368                        // makensis chdir's to the .nsi script's directory (an
369                        // ephemeral staging tempdir) before resolving a relative
370                        // `OutFile`. Under the default `dist: ./dist`, `exe_path` is
371                        // relative, so a relative OutFile would land the installer
372                        // inside the staging tempdir (which then vanishes). The
373                        // absolute path is cwd-independent and points makensis at the
374                        // real `dist/windows/` location regardless of its chdir.
375                        let exe_path = absolutize_output_path(exe_path);
376
377                        let binary_name = binary_name_raw;
378
379                        if dry_run {
380                            log.status(&format!(
381                                "(dry-run) would create NSIS installer {} for crate {} target {:?}",
382                                exe_filename, krate.name, target
383                            ));
384
385                            if let Some(ts) = &nsis_cfg.mod_timestamp {
386                                log.status(&format!("(dry-run) would apply mod_timestamp={ts}"));
387                            }
388
389                            new_artifacts.push(Artifact {
390                                kind: ArtifactKind::Installer,
391                                name: String::new(),
392                                path: exe_path,
393                                target: target.clone(),
394                                crate_name: krate.name.clone(),
395                                metadata: {
396                                    let mut m =
397                                        HashMap::from([("format".to_string(), "nsis".to_string())]);
398                                    if let Some(id) = &nsis_cfg.id {
399                                        m.insert("id".to_string(), id.clone());
400                                    }
401                                    m
402                                },
403                                size: None,
404                            });
405
406                            // If replace is set, mark archives for this crate+target for removal
407                            archives_to_remove.extend(anodizer_core::util::collect_if_replace(
408                                nsis_cfg.replace,
409                                &ctx.artifacts,
410                                &krate.name,
411                                target.as_deref(),
412                            ));
413
414                            continue;
415                        }
416
417                        // Create output directory
418                        fs::create_dir_all(&output_dir).with_context(|| {
419                            format!("create NSIS output dir: {}", output_dir.display())
420                        })?;
421
422                        // Create staging directory
423                        let staging_tmp =
424                            tempfile::tempdir().context("create temp dir for NSIS staging")?;
425                        let staging_dir = staging_tmp.path();
426
427                        // Copy binary into staging dir
428                        let staged_binary = staging_dir.join(binary_name);
429                        fs::copy(binary_path, &staged_binary).with_context(|| {
430                            format!("copy binary {} to staging dir", binary_path.display())
431                        })?;
432
433                        // Copy extra files into staging dir via the canonical
434                        // resolver (dedup + sort + bail-on-multi-match when a
435                        // name_template is set).
436                        if let Some(extra_files) = &nsis_cfg.extra_files {
437                            let resolved = anodizer_core::extrafiles::resolve(extra_files, &log)
438                                .context("nsis: resolve extra_files")?;
439                            for rf in resolved {
440                                let dst_name = rf
441                                    .name_template
442                                    .or_else(|| {
443                                        rf.path
444                                            .file_name()
445                                            .and_then(|n| n.to_str())
446                                            .map(|s| s.to_string())
447                                    })
448                                    .unwrap_or_else(|| "extra".to_string());
449                                let dst = staging_dir.join(&dst_name);
450                                fs::copy(&rf.path, &dst).with_context(|| {
451                                    format!("copy extra file {} to staging dir", rf.path.display())
452                                })?;
453                            }
454                        }
455
456                        // Process templated_extra_files: render and copy to staging dir
457                        if let Some(ref tpl_specs) = nsis_cfg.templated_extra_files
458                            && !tpl_specs.is_empty()
459                        {
460                            anodizer_core::templated_files::process_templated_extra_files(
461                                tpl_specs,
462                                ctx,
463                                staging_dir,
464                                "nsis",
465                            )?;
466                        }
467
468                        // Populate the one-shot script context with the remaining
469                        // NSIS-specific vars. name_vars already carries Arch/ProgramFiles/
470                        // Binary/Name from the name-render step above.
471                        let exe_path_str = exe_path.to_string_lossy().into_owned();
472                        let staged_binary_str = staged_binary.to_string_lossy().into_owned();
473                        name_vars.set("NsisOutputFile", &exe_path_str);
474                        name_vars.set("NsisBinaryPath", &staged_binary_str);
475                        name_vars.set("NsisBinaryName", binary_name);
476
477                        // Keep global vars in sync for mod_timestamp and anything that
478                        // follows — they use ctx.render_template, not name_vars.
479                        ctx.template_vars_mut().set("NsisOutputFile", &exe_path_str);
480                        ctx.template_vars_mut()
481                            .set("NsisBinaryPath", &staged_binary_str);
482                        ctx.template_vars_mut().set("NsisBinaryName", binary_name);
483
484                        // Get the script content (user-provided or default), render
485                        // through the one-shot context so NSIS-native vars are available.
486                        let script_content = if let Some(script_tmpl) = &nsis_cfg.script {
487                            fs::read_to_string(script_tmpl).with_context(|| {
488                                format!("nsis: read script template: {script_tmpl}")
489                            })?
490                        } else {
491                            default_nsi_script().to_string()
492                        };
493
494                        let rendered_script =
495                            anodizer_core::template::render(&script_content, &name_vars)
496                                .with_context(|| {
497                                    format!(
498                                        "nsis: render script for crate {} target {:?}",
499                                        krate.name, target
500                                    )
501                                })?;
502
503                        let nsi_script_path = staging_dir.join("installer.nsi");
504                        fs::write(&nsi_script_path, &rendered_script).with_context(|| {
505                            format!(
506                                "nsis: write rendered script to {}",
507                                nsi_script_path.display()
508                            )
509                        })?;
510
511                        // Apply mod_timestamp if set (template-rendered, to staging dir contents)
512                        if let Some(ref ts_tmpl) = nsis_cfg.mod_timestamp {
513                            let ts = ctx
514                                .render_template(ts_tmpl)
515                                .with_context(|| "nsis: render mod_timestamp template")?;
516                            anodizer_core::util::apply_mod_timestamp(staging_dir, &ts, &log)?;
517                        }
518
519                        // Build makensis command
520                        let script_path_str = nsi_script_path.to_string_lossy().into_owned();
521                        let cmd_args = nsis_command(&script_path_str);
522
523                        log.verbose(&format!("running {}", cmd_args.join(" ")));
524
525                        let output = Command::new(&cmd_args[0])
526                            .args(&cmd_args[1..])
527                            .output()
528                            .with_context(|| {
529                                format!(
530                                    "execute makensis for crate {} target {:?}",
531                                    krate.name, target
532                                )
533                            })?;
534                        log.check_output(output, "nsis")?;
535
536                        // Apply mod_timestamp to the output .exe if set (template-rendered)
537                        if let Some(ref ts_tmpl) = nsis_cfg.mod_timestamp
538                            && exe_path.exists()
539                        {
540                            let ts = ctx.render_template(ts_tmpl).with_context(
541                                || "nsis: render mod_timestamp template for output",
542                            )?;
543                            let mtime = anodizer_core::util::parse_mod_timestamp(&ts)?;
544                            anodizer_core::util::set_file_mtime(&exe_path, mtime)?;
545                            log.status(&format!(
546                                "applied mod_timestamp={ts} to {}",
547                                exe_path.display()
548                            ));
549                        }
550
551                        log.status(&format!(
552                            "built installer {}",
553                            exe_path
554                                .file_name()
555                                .map(|n| n.to_string_lossy().into_owned())
556                                .unwrap_or_else(|| exe_path.to_string_lossy().into_owned())
557                        ));
558
559                        new_artifacts.push(Artifact {
560                            kind: ArtifactKind::Installer,
561                            name: String::new(),
562                            path: exe_path,
563                            target: target.clone(),
564                            crate_name: krate.name.clone(),
565                            metadata: {
566                                let mut m =
567                                    HashMap::from([("format".to_string(), "nsis".to_string())]);
568                                if let Some(id) = &nsis_cfg.id {
569                                    m.insert("id".to_string(), id.clone());
570                                }
571                                m
572                            },
573                            size: None,
574                        });
575
576                        // If replace is set, mark archives for this crate+target for removal
577                        archives_to_remove.extend(anodizer_core::util::collect_if_replace(
578                            nsis_cfg.replace,
579                            &ctx.artifacts,
580                            &krate.name,
581                            target.as_deref(),
582                        ));
583                    }
584                }
585            }
586            Ok(())
587        })();
588
589        if multi_crate {
590            ctx.template_vars_mut()
591                .set("ProjectName", &original_project_name);
592        }
593        loop_result?;
594
595        anodizer_core::template::clear_per_target_vars(ctx.template_vars_mut());
596
597        // Remove replaced archives
598        if !archives_to_remove.is_empty() {
599            ctx.artifacts.remove_by_paths(&archives_to_remove);
600        }
601
602        // Register new NSIS artifacts
603        for artifact in new_artifacts {
604            ctx.artifacts.add(artifact);
605        }
606
607        Ok(())
608    }
609}
610
611// ---------------------------------------------------------------------------
612// Tests
613// ---------------------------------------------------------------------------
614
615/// Environment requirements for the nsis stage: `makensis` when any
616/// active `nsis:` entry exists and the configured build targets include
617/// Windows (the stage only packages Windows binaries).
618pub fn env_requirements(
619    ctx: &anodizer_core::context::Context,
620) -> Vec<anodizer_core::EnvRequirement> {
621    if !anodizer_core::env_preflight::configured_build_targets(ctx)
622        .iter()
623        .any(|t| anodizer_core::target::is_windows(t))
624    {
625        return Vec::new();
626    }
627    let configured = anodizer_core::env_preflight::crate_universe(&ctx.config)
628        .into_iter()
629        .flat_map(|c| c.nsis.iter().flatten())
630        .any(|cfg| {
631            !anodizer_core::env_preflight::entry_inactive(
632                ctx,
633                cfg.skip.as_ref(),
634                None,
635                cfg.if_condition.as_deref(),
636            )
637        });
638    if !configured {
639        return Vec::new();
640    }
641    vec![anodizer_core::EnvRequirement::Tool {
642        name: "makensis".to_string(),
643    }]
644}
645
646#[cfg(test)]
647#[allow(clippy::field_reassign_with_default)]
648mod tests {
649    use super::*;
650    use std::path::PathBuf;
651
652    // -----------------------------------------------------------------------
653    // Default NSI script generation
654    // -----------------------------------------------------------------------
655
656    #[test]
657    fn test_default_nsi_script_generation() {
658        let script = default_nsi_script();
659
660        assert!(
661            script.contains("!include \"MUI2.nsh\""),
662            "should include MUI2"
663        );
664        assert!(
665            script.contains("Name \"{{ ProjectName }}\""),
666            "should reference ProjectName"
667        );
668        // OutFile must be the absolute NsisOutputFile (the recorded artifact
669        // path), never a bare relative filename — makensis resolves a relative
670        // OutFile against the ephemeral staging dir.
671        assert!(
672            script.contains("OutFile \"{{ NsisOutputFile }}\""),
673            "should use the absolute NsisOutputFile var for OutFile"
674        );
675        assert!(
676            !script.contains("OutFile \"{{ Name }}.exe\""),
677            "OutFile must not be a bare relative filename"
678        );
679        // Default script uses ProgramFiles (arch-aware) instead of the hardcoded $PROGRAMFILES
680        assert!(
681            script.contains("InstallDir \"{{ ProgramFiles }}\\{{ ProjectName }}\""),
682            "should use ProgramFiles var for InstallDir"
683        );
684        assert!(
685            !script.contains("$PROGRAMFILES\\"),
686            "should not hardcode $PROGRAMFILES (use ProgramFiles var instead)"
687        );
688        assert!(
689            script.contains("RequestExecutionLevel admin"),
690            "should request admin execution level"
691        );
692        assert!(
693            script.contains("Section \"Install\""),
694            "should have Install section"
695        );
696        assert!(
697            script.contains("File \"{{ NsisBinaryPath }}\""),
698            "should include the binary via template var"
699        );
700        assert!(
701            script.contains("Section \"Uninstall\""),
702            "should have Uninstall section"
703        );
704        assert!(
705            script.contains("Delete \"$INSTDIR\\{{ NsisBinaryName }}\""),
706            "uninstaller should delete the binary"
707        );
708        assert!(
709            script.contains("Delete \"$INSTDIR\\uninstall.exe\""),
710            "uninstaller should delete itself"
711        );
712        assert!(
713            script.contains("RMDir \"$INSTDIR\""),
714            "should remove install dir"
715        );
716        assert!(
717            script.contains("CreateShortCut"),
718            "should create a desktop shortcut"
719        );
720        assert!(
721            script.contains("WriteUninstaller"),
722            "should write the uninstaller"
723        );
724    }
725
726    #[test]
727    fn test_map_arch_to_nsis() {
728        assert_eq!(map_arch_to_nsis("amd64"), "x64");
729        assert_eq!(map_arch_to_nsis("x86_64"), "x64");
730        assert_eq!(map_arch_to_nsis("386"), "x86");
731        assert_eq!(map_arch_to_nsis("i386"), "x86");
732        assert_eq!(map_arch_to_nsis("i686"), "x86");
733        assert_eq!(map_arch_to_nsis("arm64"), "arm64");
734        assert_eq!(map_arch_to_nsis("aarch64"), "arm64");
735        assert_eq!(map_arch_to_nsis("riscv64"), "riscv64");
736    }
737
738    #[test]
739    fn test_program_files_for_arch() {
740        assert_eq!(program_files_for_arch("x64"), "$PROGRAMFILES64");
741        assert_eq!(program_files_for_arch("arm64"), "$PROGRAMFILES64");
742        assert_eq!(program_files_for_arch("x86"), "$PROGRAMFILES");
743        assert_eq!(program_files_for_arch("other"), "$PROGRAMFILES");
744    }
745
746    // -----------------------------------------------------------------------
747    // Default name template renders with NSIS-native arch
748    // -----------------------------------------------------------------------
749
750    #[test]
751    fn test_default_name_template_uses_nsis_arch() {
752        // The default name template uses `Arch`, which is overridden to the
753        // NSIS-native value in the one-shot context before rendering.
754        // For x86_64-pc-windows-msvc: Go arch = "amd64" -> NSIS arch = "x64".
755        use anodizer_core::config::{Config, CrateConfig, NsisConfig};
756        use anodizer_core::context::{Context, ContextOptions};
757
758        let tmp = tempfile::TempDir::new().unwrap();
759        let mut config = Config::default();
760        config.project_name = "myapp".to_string();
761        config.dist = tmp.path().join("dist");
762        config.crates = vec![CrateConfig {
763            name: "myapp".to_string(),
764            path: ".".to_string(),
765            tag_template: "v{{ .Version }}".to_string(),
766            nsis: Some(vec![NsisConfig::default()]),
767            ..Default::default()
768        }];
769
770        let mut ctx = Context::new(
771            config,
772            ContextOptions {
773                dry_run: true,
774                ..Default::default()
775            },
776        );
777        ctx.template_vars_mut().set("Version", "1.0.0");
778        ctx.artifacts.add(Artifact {
779            kind: ArtifactKind::Binary,
780            name: String::new(),
781            path: PathBuf::from("dist/myapp.exe"),
782            target: Some("x86_64-pc-windows-msvc".to_string()),
783            crate_name: "myapp".to_string(),
784            metadata: Default::default(),
785            size: None,
786        });
787
788        NsisStage.run(&mut ctx).unwrap();
789        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
790        assert_eq!(installers.len(), 1);
791        let path = installers[0].path.to_string_lossy();
792        // Default template `{{ ProjectName }}_{{ Arch }}_setup` renders with the
793        // NSIS-native arch (x64) and gains the auto-appended `.exe`.
794        assert!(
795            path.ends_with("myapp_x64_setup.exe"),
796            "expected NSIS-native arch + .exe in filename, got: {path}"
797        );
798    }
799
800    // -----------------------------------------------------------------------
801    // makensis command construction
802    // -----------------------------------------------------------------------
803
804    #[test]
805    fn test_nsis_command_args() {
806        let cmd = nsis_command("/tmp/staging/installer.nsi");
807
808        assert_eq!(cmd[0], "makensis");
809        assert_eq!(cmd[1], "/tmp/staging/installer.nsi");
810        assert_eq!(cmd.len(), 2);
811    }
812
813    // -----------------------------------------------------------------------
814    // Stage behavior tests
815    // -----------------------------------------------------------------------
816
817    #[test]
818    fn test_stage_skips_when_no_nsis_config() {
819        use anodizer_core::config::Config;
820        use anodizer_core::context::{Context, ContextOptions};
821
822        let config = Config::default();
823        let mut ctx = Context::new(config, ContextOptions::default());
824        let stage = NsisStage;
825        assert!(stage.run(&mut ctx).is_ok());
826        assert!(ctx.artifacts.all().is_empty());
827    }
828
829    #[test]
830    fn test_stage_skips_when_disabled() {
831        use anodizer_core::config::{Config, CrateConfig, NsisConfig, StringOrBool};
832        use anodizer_core::context::{Context, ContextOptions};
833
834        let tmp = tempfile::TempDir::new().unwrap();
835
836        let nsis_cfg = NsisConfig {
837            skip: Some(StringOrBool::Bool(true)),
838            ..Default::default()
839        };
840
841        let crate_cfg = CrateConfig {
842            name: "myapp".to_string(),
843            path: ".".to_string(),
844            tag_template: "v{{ .Version }}".to_string(),
845            nsis: Some(vec![nsis_cfg]),
846            ..Default::default()
847        };
848
849        let mut config = Config::default();
850        config.project_name = "myapp".to_string();
851        config.dist = tmp.path().join("dist");
852        config.crates = vec![crate_cfg];
853
854        let mut ctx = Context::new(
855            config,
856            ContextOptions {
857                dry_run: true,
858                ..Default::default()
859            },
860        );
861        ctx.template_vars_mut().set("Version", "1.0.0");
862
863        // Add a Windows binary so the stage has something to potentially process
864        ctx.artifacts.add(Artifact {
865            kind: ArtifactKind::Binary,
866            name: String::new(),
867            path: PathBuf::from("dist/myapp.exe"),
868            target: Some("x86_64-pc-windows-msvc".to_string()),
869            crate_name: "myapp".to_string(),
870            metadata: Default::default(),
871            size: None,
872        });
873
874        let stage = NsisStage;
875        stage.run(&mut ctx).unwrap();
876
877        // No installer artifacts should be produced because config is disabled
878        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
879        assert!(installers.is_empty());
880    }
881
882    #[test]
883    fn test_stage_skips_when_disabled_via_template() {
884        use anodizer_core::config::{Config, CrateConfig, NsisConfig, StringOrBool};
885        use anodizer_core::context::{Context, ContextOptions};
886
887        let tmp = tempfile::TempDir::new().unwrap();
888
889        // Template evaluates to "true" when IsSnapshot is set
890        let nsis_cfg = NsisConfig {
891            skip: Some(StringOrBool::String("{{ IsSnapshot }}".to_string())),
892            ..Default::default()
893        };
894
895        let mut config = Config::default();
896        config.project_name = "myapp".to_string();
897        config.dist = tmp.path().join("dist");
898        config.crates = vec![CrateConfig {
899            name: "myapp".to_string(),
900            path: ".".to_string(),
901            tag_template: "v{{ .Version }}".to_string(),
902            nsis: Some(vec![nsis_cfg]),
903            ..Default::default()
904        }];
905
906        let mut ctx = Context::new(
907            config,
908            ContextOptions {
909                dry_run: true,
910                ..Default::default()
911            },
912        );
913        ctx.template_vars_mut().set("Version", "1.0.0");
914        ctx.template_vars_mut().set("IsSnapshot", "true");
915
916        ctx.artifacts.add(Artifact {
917            kind: ArtifactKind::Binary,
918            name: String::new(),
919            path: PathBuf::from("dist/myapp.exe"),
920            target: Some("x86_64-pc-windows-msvc".to_string()),
921            crate_name: "myapp".to_string(),
922            metadata: Default::default(),
923            size: None,
924        });
925
926        let stage = NsisStage;
927        stage.run(&mut ctx).unwrap();
928
929        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
930        assert!(installers.is_empty(), "should be disabled by template");
931    }
932
933    #[test]
934    fn test_stage_dry_run_registers_artifacts() {
935        use anodizer_core::config::{Config, CrateConfig, NsisConfig};
936        use anodizer_core::context::{Context, ContextOptions};
937
938        let tmp = tempfile::TempDir::new().unwrap();
939
940        let nsis_cfg = NsisConfig::default();
941
942        let crate_cfg = CrateConfig {
943            name: "myapp".to_string(),
944            path: ".".to_string(),
945            tag_template: "v{{ .Version }}".to_string(),
946            nsis: Some(vec![nsis_cfg]),
947            ..Default::default()
948        };
949
950        let mut config = Config::default();
951        config.project_name = "myapp".to_string();
952        config.dist = tmp.path().join("dist");
953        config.crates = vec![crate_cfg];
954
955        let mut ctx = Context::new(
956            config,
957            ContextOptions {
958                dry_run: true,
959                ..Default::default()
960            },
961        );
962        ctx.template_vars_mut().set("Version", "1.0.0");
963
964        // Register Windows binary artifacts
965        ctx.artifacts.add(Artifact {
966            kind: ArtifactKind::Binary,
967            name: String::new(),
968            path: PathBuf::from("dist/myapp.exe"),
969            target: Some("x86_64-pc-windows-msvc".to_string()),
970            crate_name: "myapp".to_string(),
971            metadata: Default::default(),
972            size: None,
973        });
974        ctx.artifacts.add(Artifact {
975            kind: ArtifactKind::Binary,
976            name: String::new(),
977            path: PathBuf::from("dist/myapp_arm.exe"),
978            target: Some("aarch64-pc-windows-msvc".to_string()),
979            crate_name: "myapp".to_string(),
980            metadata: Default::default(),
981            size: None,
982        });
983
984        let stage = NsisStage;
985        stage.run(&mut ctx).unwrap();
986
987        // Two Windows binaries -> two installer artifacts
988        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
989        assert_eq!(installers.len(), 2);
990
991        // All should have format=nsis metadata
992        for inst in &installers {
993            assert_eq!(inst.metadata.get("format").unwrap(), "nsis");
994            assert_eq!(inst.kind, ArtifactKind::Installer);
995        }
996
997        // Check targets are preserved
998        let targets: Vec<&str> = installers
999            .iter()
1000            .map(|a| a.target.as_deref().unwrap())
1001            .collect();
1002        assert!(targets.contains(&"x86_64-pc-windows-msvc"));
1003        assert!(targets.contains(&"aarch64-pc-windows-msvc"));
1004    }
1005
1006    #[test]
1007    fn test_stage_dry_run_with_name_template() {
1008        use anodizer_core::config::{Config, CrateConfig, NsisConfig};
1009        use anodizer_core::context::{Context, ContextOptions};
1010
1011        let tmp = tempfile::TempDir::new().unwrap();
1012
1013        let nsis_cfg = NsisConfig {
1014            name: Some("{{ ProjectName }}-{{ Version }}-{{ Arch }}-setup.exe".to_string()),
1015            ..Default::default()
1016        };
1017
1018        let crate_cfg = CrateConfig {
1019            name: "myapp".to_string(),
1020            path: ".".to_string(),
1021            tag_template: "v{{ .Version }}".to_string(),
1022            nsis: Some(vec![nsis_cfg]),
1023            ..Default::default()
1024        };
1025
1026        let mut config = Config::default();
1027        config.project_name = "myapp".to_string();
1028        config.dist = tmp.path().join("dist");
1029        config.crates = vec![crate_cfg];
1030
1031        let mut ctx = Context::new(
1032            config,
1033            ContextOptions {
1034                dry_run: true,
1035                ..Default::default()
1036            },
1037        );
1038        ctx.template_vars_mut().set("Version", "2.0.0");
1039
1040        ctx.artifacts.add(Artifact {
1041            kind: ArtifactKind::Binary,
1042            name: String::new(),
1043            path: PathBuf::from("dist/myapp.exe"),
1044            target: Some("x86_64-pc-windows-msvc".to_string()),
1045            crate_name: "myapp".to_string(),
1046            metadata: Default::default(),
1047            size: None,
1048        });
1049
1050        let stage = NsisStage;
1051        stage.run(&mut ctx).unwrap();
1052
1053        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
1054        assert_eq!(installers.len(), 1);
1055
1056        let installer_path = installers[0].path.to_string_lossy();
1057        // Arch in user name templates is NSIS-native: x86_64 maps to x64
1058        assert!(
1059            installer_path.ends_with("myapp-2.0.0-x64-setup.exe"),
1060            "expected NSIS-native arch in template-rendered name, got: {installer_path}"
1061        );
1062    }
1063
1064    #[test]
1065    fn test_stage_dry_run_replace_removes_archives() {
1066        use anodizer_core::config::{Config, CrateConfig, NsisConfig};
1067        use anodizer_core::context::{Context, ContextOptions};
1068
1069        let tmp = tempfile::TempDir::new().unwrap();
1070
1071        let nsis_cfg = NsisConfig {
1072            replace: Some(true),
1073            ..Default::default()
1074        };
1075
1076        let crate_cfg = CrateConfig {
1077            name: "myapp".to_string(),
1078            path: ".".to_string(),
1079            tag_template: "v{{ .Version }}".to_string(),
1080            nsis: Some(vec![nsis_cfg]),
1081            ..Default::default()
1082        };
1083
1084        let mut config = Config::default();
1085        config.project_name = "myapp".to_string();
1086        config.dist = tmp.path().join("dist");
1087        config.crates = vec![crate_cfg];
1088
1089        let mut ctx = Context::new(
1090            config,
1091            ContextOptions {
1092                dry_run: true,
1093                ..Default::default()
1094            },
1095        );
1096        ctx.template_vars_mut().set("Version", "1.0.0");
1097
1098        // Register a Windows binary
1099        ctx.artifacts.add(Artifact {
1100            kind: ArtifactKind::Binary,
1101            name: String::new(),
1102            path: PathBuf::from("dist/myapp.exe"),
1103            target: Some("x86_64-pc-windows-msvc".to_string()),
1104            crate_name: "myapp".to_string(),
1105            metadata: Default::default(),
1106            size: None,
1107        });
1108
1109        // Register an archive artifact for the same crate+target
1110        ctx.artifacts.add(Artifact {
1111            kind: ArtifactKind::Archive,
1112            name: String::new(),
1113            path: PathBuf::from("dist/myapp_1.0.0_windows_amd64.zip"),
1114            target: Some("x86_64-pc-windows-msvc".to_string()),
1115            crate_name: "myapp".to_string(),
1116            metadata: HashMap::from([("format".to_string(), "zip".to_string())]),
1117            size: None,
1118        });
1119
1120        // Also register a Linux archive that should NOT be removed
1121        ctx.artifacts.add(Artifact {
1122            kind: ArtifactKind::Archive,
1123            name: String::new(),
1124            path: PathBuf::from("dist/myapp_1.0.0_linux_amd64.tar.gz"),
1125            target: Some("x86_64-unknown-linux-gnu".to_string()),
1126            crate_name: "myapp".to_string(),
1127            metadata: HashMap::from([("format".to_string(), "tar.gz".to_string())]),
1128            size: None,
1129        });
1130
1131        let stage = NsisStage;
1132        stage.run(&mut ctx).unwrap();
1133
1134        // NSIS installer artifact should be registered
1135        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
1136        assert_eq!(installers.len(), 1);
1137
1138        // The Windows archive should have been removed (replace: true)
1139        let archives = ctx.artifacts.by_kind(ArtifactKind::Archive);
1140        assert_eq!(archives.len(), 1, "only the Linux archive should remain");
1141        assert!(
1142            archives[0].target.as_deref().unwrap().contains("linux"),
1143            "remaining archive should be the Linux one"
1144        );
1145    }
1146
1147    #[test]
1148    fn test_stage_ignores_non_windows_binaries() {
1149        use anodizer_core::config::{Config, CrateConfig, NsisConfig};
1150        use anodizer_core::context::{Context, ContextOptions};
1151
1152        let tmp = tempfile::TempDir::new().unwrap();
1153
1154        let nsis_cfg = NsisConfig::default();
1155
1156        let mut config = Config::default();
1157        config.project_name = "myapp".to_string();
1158        config.dist = tmp.path().join("dist");
1159        config.crates = vec![CrateConfig {
1160            name: "myapp".to_string(),
1161            path: ".".to_string(),
1162            tag_template: "v{{ .Version }}".to_string(),
1163            nsis: Some(vec![nsis_cfg]),
1164            ..Default::default()
1165        }];
1166
1167        let mut ctx = Context::new(
1168            config,
1169            ContextOptions {
1170                dry_run: true,
1171                ..Default::default()
1172            },
1173        );
1174        ctx.template_vars_mut().set("Version", "1.0.0");
1175
1176        // Only add Linux and macOS binaries — no Windows binaries
1177        ctx.artifacts.add(Artifact {
1178            kind: ArtifactKind::Binary,
1179            name: String::new(),
1180            path: PathBuf::from("dist/myapp"),
1181            target: Some("x86_64-unknown-linux-gnu".to_string()),
1182            crate_name: "myapp".to_string(),
1183            metadata: Default::default(),
1184            size: None,
1185        });
1186        ctx.artifacts.add(Artifact {
1187            kind: ArtifactKind::Binary,
1188            name: String::new(),
1189            path: PathBuf::from("dist/myapp_darwin"),
1190            target: Some("aarch64-apple-darwin".to_string()),
1191            crate_name: "myapp".to_string(),
1192            metadata: Default::default(),
1193            size: None,
1194        });
1195
1196        let stage = NsisStage;
1197        stage.run(&mut ctx).unwrap();
1198
1199        // No installer artifacts — no Windows binaries available
1200        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
1201        assert!(
1202            installers.is_empty(),
1203            "should produce no installers for non-Windows binaries"
1204        );
1205    }
1206
1207    #[test]
1208    fn test_config_parse_nsis() {
1209        let yaml = r#"
1210project_name: test
1211crates:
1212  - name: test
1213    path: "."
1214    tag_template: "v{{ .Version }}"
1215    nsis:
1216      - name: "{{ ProjectName }}_{{ Version }}_{{ Arch }}_setup.exe"
1217"#;
1218        let config: anodizer_core::config::Config = serde_yaml_ng::from_str(yaml).unwrap();
1219        let nsis_configs = config.crates[0].nsis.as_ref().unwrap();
1220        assert_eq!(nsis_configs.len(), 1);
1221        assert_eq!(
1222            nsis_configs[0].name.as_deref(),
1223            Some("{{ ProjectName }}_{{ Version }}_{{ Arch }}_setup.exe")
1224        );
1225        assert!(nsis_configs[0].skip.is_none());
1226        assert!(nsis_configs[0].replace.is_none());
1227        assert!(nsis_configs[0].script.is_none());
1228    }
1229
1230    #[test]
1231    fn test_config_parse_nsis_full() {
1232        let yaml = r#"
1233project_name: test
1234crates:
1235  - name: test
1236    path: "."
1237    tag_template: "v{{ .Version }}"
1238    nsis:
1239      - id: windows-nsis
1240        ids:
1241          - build_windows_amd64
1242          - build_windows_arm64
1243        name: "myapp-{{ Version }}-{{ Arch }}-setup.exe"
1244        script: "installer.nsi"
1245        extra_files:
1246          - README.md
1247          - LICENSE
1248        replace: true
1249        mod_timestamp: "{{ .CommitTimestamp }}"
1250        skip: "false"
1251"#;
1252        let config: anodizer_core::config::Config = serde_yaml_ng::from_str(yaml).unwrap();
1253        let nsis_configs = config.crates[0].nsis.as_ref().unwrap();
1254        assert_eq!(nsis_configs.len(), 1);
1255
1256        let nsis = &nsis_configs[0];
1257        assert_eq!(nsis.id.as_deref(), Some("windows-nsis"));
1258        assert_eq!(
1259            nsis.ids.as_ref().unwrap(),
1260            &vec![
1261                "build_windows_amd64".to_string(),
1262                "build_windows_arm64".to_string()
1263            ]
1264        );
1265        assert_eq!(
1266            nsis.name.as_deref(),
1267            Some("myapp-{{ Version }}-{{ Arch }}-setup.exe")
1268        );
1269        assert_eq!(nsis.script.as_deref(), Some("installer.nsi"));
1270        assert_eq!(nsis.replace, Some(true));
1271        assert_eq!(
1272            nsis.mod_timestamp.as_deref(),
1273            Some("{{ .CommitTimestamp }}")
1274        );
1275        assert_eq!(
1276            nsis.skip,
1277            Some(anodizer_core::config::StringOrBool::String(
1278                "false".to_string()
1279            ))
1280        );
1281    }
1282
1283    #[test]
1284    fn test_invalid_name_template_errors() {
1285        use anodizer_core::config::{Config, CrateConfig, NsisConfig};
1286        use anodizer_core::context::{Context, ContextOptions};
1287
1288        let tmp = tempfile::TempDir::new().unwrap();
1289
1290        let nsis_cfg = NsisConfig {
1291            // Tera will error on unclosed tags
1292            name: Some("{{ ProjectName }}_{{ Version".to_string()),
1293            ..Default::default()
1294        };
1295
1296        let crate_cfg = CrateConfig {
1297            name: "myapp".to_string(),
1298            path: ".".to_string(),
1299            tag_template: "v{{ .Version }}".to_string(),
1300            nsis: Some(vec![nsis_cfg]),
1301            ..Default::default()
1302        };
1303
1304        let mut config = Config::default();
1305        config.project_name = "myapp".to_string();
1306        config.dist = tmp.path().join("dist");
1307        config.crates = vec![crate_cfg];
1308
1309        let mut ctx = Context::new(
1310            config,
1311            ContextOptions {
1312                dry_run: true,
1313                ..Default::default()
1314            },
1315        );
1316        ctx.template_vars_mut().set("Version", "1.0.0");
1317
1318        // Add a Windows binary so we actually attempt to render the template
1319        ctx.artifacts.add(Artifact {
1320            kind: ArtifactKind::Binary,
1321            name: String::new(),
1322            path: PathBuf::from("dist/myapp.exe"),
1323            target: Some("x86_64-pc-windows-msvc".to_string()),
1324            crate_name: "myapp".to_string(),
1325            metadata: Default::default(),
1326            size: None,
1327        });
1328
1329        let stage = NsisStage;
1330        let result = stage.run(&mut ctx);
1331        assert!(result.is_err(), "should error on invalid template");
1332    }
1333
1334    #[test]
1335    fn test_stage_ids_filter() {
1336        use anodizer_core::config::{Config, CrateConfig, NsisConfig};
1337        use anodizer_core::context::{Context, ContextOptions};
1338
1339        let tmp = tempfile::TempDir::new().unwrap();
1340
1341        let nsis_cfg = NsisConfig {
1342            ids: Some(vec!["build_amd64".to_string()]),
1343            ..Default::default()
1344        };
1345
1346        let mut config = Config::default();
1347        config.project_name = "myapp".to_string();
1348        config.dist = tmp.path().join("dist");
1349        config.crates = vec![CrateConfig {
1350            name: "myapp".to_string(),
1351            path: ".".to_string(),
1352            tag_template: "v{{ .Version }}".to_string(),
1353            nsis: Some(vec![nsis_cfg]),
1354            ..Default::default()
1355        }];
1356
1357        let mut ctx = Context::new(
1358            config,
1359            ContextOptions {
1360                dry_run: true,
1361                ..Default::default()
1362            },
1363        );
1364        ctx.template_vars_mut().set("Version", "1.0.0");
1365
1366        // Add two Windows binaries with different IDs
1367        ctx.artifacts.add(Artifact {
1368            kind: ArtifactKind::Binary,
1369            name: String::new(),
1370            path: PathBuf::from("dist/myapp_amd64.exe"),
1371            target: Some("x86_64-pc-windows-msvc".to_string()),
1372            crate_name: "myapp".to_string(),
1373            metadata: HashMap::from([("id".to_string(), "build_amd64".to_string())]),
1374            size: None,
1375        });
1376        ctx.artifacts.add(Artifact {
1377            kind: ArtifactKind::Binary,
1378            name: String::new(),
1379            path: PathBuf::from("dist/myapp_arm64.exe"),
1380            target: Some("aarch64-pc-windows-msvc".to_string()),
1381            crate_name: "myapp".to_string(),
1382            metadata: HashMap::from([("id".to_string(), "build_arm64".to_string())]),
1383            size: None,
1384        });
1385
1386        let stage = NsisStage;
1387        stage.run(&mut ctx).unwrap();
1388
1389        // Only the amd64 binary should produce an installer
1390        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
1391        assert_eq!(installers.len(), 1);
1392        assert_eq!(
1393            installers[0].target.as_deref().unwrap(),
1394            "x86_64-pc-windows-msvc"
1395        );
1396    }
1397
1398    /// The recorded `Installer` artifact path — what every downstream stage
1399    /// (sign, checksum, upload) and makensis itself reference — must end in
1400    /// `.exe`. The extension-less default/user `name` template gains `.exe`
1401    /// after rendering (mirroring how dmg/pkg append `.dmg`/`.pkg`).
1402    #[test]
1403    fn test_stage_exe_extension_appended() {
1404        use anodizer_core::config::{Config, CrateConfig, NsisConfig};
1405        use anodizer_core::context::{Context, ContextOptions};
1406
1407        let tmp = tempfile::TempDir::new().unwrap();
1408        let nsis_cfg = NsisConfig {
1409            name: Some("{{ ProjectName }}_{{ Arch }}_setup".to_string()),
1410            ..Default::default()
1411        };
1412
1413        let mut config = Config::default();
1414        config.project_name = "myapp".to_string();
1415        config.dist = tmp.path().join("dist");
1416        config.crates = vec![CrateConfig {
1417            name: "myapp".to_string(),
1418            path: ".".to_string(),
1419            tag_template: "v{{ .Version }}".to_string(),
1420            nsis: Some(vec![nsis_cfg]),
1421            ..Default::default()
1422        }];
1423
1424        let mut ctx = Context::new(
1425            config,
1426            ContextOptions {
1427                dry_run: true,
1428                ..Default::default()
1429            },
1430        );
1431        ctx.template_vars_mut().set("Version", "1.0.0");
1432
1433        ctx.artifacts.add(Artifact {
1434            kind: ArtifactKind::Binary,
1435            name: String::new(),
1436            path: PathBuf::from("dist/myapp.exe"),
1437            target: Some("x86_64-pc-windows-msvc".to_string()),
1438            crate_name: "myapp".to_string(),
1439            metadata: Default::default(),
1440            size: None,
1441        });
1442
1443        NsisStage.run(&mut ctx).unwrap();
1444
1445        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
1446        assert_eq!(installers.len(), 1);
1447        let path = installers[0].path.to_string_lossy();
1448        assert!(
1449            path.ends_with("myapp_x64_setup.exe"),
1450            ".exe must be appended to the recorded artifact path, got: {path}"
1451        );
1452    }
1453
1454    /// A user `name` that already ends in `.exe` (any case) must not be
1455    /// double-appended.
1456    #[test]
1457    fn test_stage_exe_extension_not_double_appended() {
1458        use anodizer_core::config::{Config, CrateConfig, NsisConfig};
1459        use anodizer_core::context::{Context, ContextOptions};
1460
1461        for literal in ["myapp_setup.exe", "myapp_setup.EXE"] {
1462            let tmp = tempfile::TempDir::new().unwrap();
1463            let nsis_cfg = NsisConfig {
1464                name: Some(literal.to_string()),
1465                ..Default::default()
1466            };
1467
1468            let mut config = Config::default();
1469            config.project_name = "myapp".to_string();
1470            config.dist = tmp.path().join("dist");
1471            config.crates = vec![CrateConfig {
1472                name: "myapp".to_string(),
1473                path: ".".to_string(),
1474                tag_template: "v{{ .Version }}".to_string(),
1475                nsis: Some(vec![nsis_cfg]),
1476                ..Default::default()
1477            }];
1478
1479            let mut ctx = Context::new(
1480                config,
1481                ContextOptions {
1482                    dry_run: true,
1483                    ..Default::default()
1484                },
1485            );
1486            ctx.template_vars_mut().set("Version", "1.0.0");
1487            ctx.artifacts.add(Artifact {
1488                kind: ArtifactKind::Binary,
1489                name: String::new(),
1490                path: PathBuf::from("dist/myapp.exe"),
1491                target: Some("x86_64-pc-windows-msvc".to_string()),
1492                crate_name: "myapp".to_string(),
1493                metadata: Default::default(),
1494                size: None,
1495            });
1496
1497            NsisStage.run(&mut ctx).unwrap();
1498
1499            let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
1500            assert_eq!(installers.len(), 1);
1501            let path = installers[0].path.to_string_lossy();
1502            assert!(
1503                path.ends_with(literal),
1504                "existing .exe must not be double-appended, got: {path} (name was {literal})"
1505            );
1506            assert!(
1507                !path.to_ascii_lowercase().ends_with(".exe.exe"),
1508                "double .exe append, got: {path}"
1509            );
1510        }
1511    }
1512
1513    // --- `nsis.if` template-conditional ---
1514
1515    fn nsis_if_test_ctx(if_expr: Option<&str>) -> anodizer_core::context::Context {
1516        use anodizer_core::artifact::{Artifact, ArtifactKind};
1517        use anodizer_core::config::{Config, CrateConfig, NsisConfig};
1518        use anodizer_core::context::{Context, ContextOptions};
1519        let tmp = tempfile::TempDir::new().unwrap();
1520        let mut config = Config::default();
1521        config.project_name = "myapp".to_string();
1522        config.dist = tmp.path().join("dist");
1523        std::fs::create_dir_all(&config.dist).unwrap();
1524        let nsis_cfg = NsisConfig {
1525            script: Some("installer.nsi".to_string()),
1526            if_condition: if_expr.map(str::to_string),
1527            ..Default::default()
1528        };
1529        config.crates = vec![CrateConfig {
1530            name: "myapp".to_string(),
1531            path: ".".to_string(),
1532            tag_template: "v{{ .Version }}".to_string(),
1533            nsis: Some(vec![nsis_cfg]),
1534            ..Default::default()
1535        }];
1536        let mut ctx = Context::new(
1537            config,
1538            ContextOptions {
1539                dry_run: true,
1540                ..Default::default()
1541            },
1542        );
1543        ctx.template_vars_mut().set("Version", "1.0.0");
1544        ctx.template_vars_mut().set("Os", "windows");
1545        ctx.artifacts.add(Artifact {
1546            kind: ArtifactKind::Binary,
1547            name: String::new(),
1548            path: std::path::PathBuf::from("dist/myapp.exe"),
1549            target: Some("x86_64-pc-windows-msvc".to_string()),
1550            crate_name: "myapp".to_string(),
1551            metadata: Default::default(),
1552            size: None,
1553        });
1554        ctx
1555    }
1556
1557    #[test]
1558    fn test_nsis_if_false_skips_config() {
1559        use anodizer_core::artifact::ArtifactKind;
1560        let mut ctx = nsis_if_test_ctx(Some("false"));
1561        NsisStage.run(&mut ctx).unwrap();
1562        assert_eq!(
1563            ctx.artifacts.by_kind(ArtifactKind::Installer).len(),
1564            0,
1565            "nsis if=false should skip"
1566        );
1567    }
1568
1569    #[test]
1570    fn test_nsis_if_render_failure_is_hard_error() {
1571        let mut ctx = nsis_if_test_ctx(Some("{{ undefined_function 42 }}"));
1572        let err = NsisStage
1573            .run(&mut ctx)
1574            .expect_err("unrenderable `if` should hard-error");
1575        let msg = format!("{:#}", err);
1576        assert!(
1577            msg.contains("`if` template render failed"),
1578            "error should name `if` render failure, got: {msg}"
1579        );
1580    }
1581
1582    // -------------------------------------------------------------------
1583    // `nsis.amd64_variant` filter
1584    // -------------------------------------------------------------------
1585
1586    /// Build a context with three windows/amd64 binaries (v1/v2/v3) +
1587    /// one windows/arm64 binary. The `amd64_variant` field on the config drives
1588    /// which subset of amd64 binaries reaches NSIS Installer artifact creation.
1589    fn nsis_amd64_variant_test_ctx(amd64_variant: Option<&str>) -> anodizer_core::context::Context {
1590        use anodizer_core::artifact::Artifact;
1591        use anodizer_core::config::{CrateConfig, NsisConfig};
1592        use anodizer_core::context::{Context, ContextOptions};
1593
1594        let tmp = tempfile::TempDir::new().unwrap();
1595        let script_path = tmp.path().join("installer.nsi");
1596        std::fs::write(&script_path, "OutFile \"out.exe\"\nSection\nSectionEnd\n").unwrap();
1597
1598        let nsis_cfg = NsisConfig {
1599            script: Some(script_path.to_string_lossy().into_owned()),
1600            amd64_variant: amd64_variant.map(str::to_string),
1601            ..Default::default()
1602        };
1603
1604        let mut config = anodizer_core::config::Config::default();
1605        config.project_name = "myapp".to_string();
1606        config.dist = tmp.path().join("dist");
1607        std::fs::create_dir_all(&config.dist).unwrap();
1608        config.crates = vec![CrateConfig {
1609            name: "myapp".to_string(),
1610            path: ".".to_string(),
1611            tag_template: "v{{ .Version }}".to_string(),
1612            nsis: Some(vec![nsis_cfg]),
1613            ..Default::default()
1614        }];
1615
1616        let mut ctx = Context::new(
1617            config,
1618            ContextOptions {
1619                dry_run: true,
1620                ..Default::default()
1621            },
1622        );
1623        ctx.template_vars_mut().set("Version", "1.0.0");
1624
1625        for variant in ["v1", "v2", "v3"] {
1626            ctx.artifacts.add(Artifact {
1627                kind: ArtifactKind::Binary,
1628                name: String::new(),
1629                path: PathBuf::from(format!("dist/myapp_{variant}.exe")),
1630                target: Some("x86_64-pc-windows-msvc".to_string()),
1631                crate_name: "myapp".to_string(),
1632                metadata: HashMap::from([("amd64_variant".to_string(), variant.to_string())]),
1633                size: None,
1634            });
1635        }
1636        ctx.artifacts.add(Artifact {
1637            kind: ArtifactKind::Binary,
1638            name: String::new(),
1639            path: PathBuf::from("dist/myapp_arm.exe"),
1640            target: Some("aarch64-pc-windows-msvc".to_string()),
1641            crate_name: "myapp".to_string(),
1642            metadata: HashMap::new(),
1643            size: None,
1644        });
1645        ctx
1646    }
1647
1648    #[test]
1649    fn test_nsis_amd64_variant_unset_passes_all_amd64_variants() {
1650        let mut ctx = nsis_amd64_variant_test_ctx(None);
1651        NsisStage.run(&mut ctx).unwrap();
1652        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
1653        // 3 amd64 variants + 1 arm64 -> 4 NSIS installers.
1654        assert_eq!(installers.len(), 4);
1655    }
1656
1657    #[test]
1658    fn test_nsis_amd64_variant_v3_only_keeps_matching_variant() {
1659        let mut ctx = nsis_amd64_variant_test_ctx(Some("v3"));
1660        NsisStage.run(&mut ctx).unwrap();
1661        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
1662        // Only v3 amd64 + arm64 -> 2 installers.
1663        assert_eq!(installers.len(), 2);
1664        let targets: Vec<&str> = installers
1665            .iter()
1666            .map(|a| a.target.as_deref().unwrap())
1667            .collect();
1668        assert!(targets.contains(&"x86_64-pc-windows-msvc"));
1669        assert!(targets.contains(&"aarch64-pc-windows-msvc"));
1670    }
1671
1672    #[test]
1673    fn test_nsis_amd64_variant_filter_does_not_drop_arm64() {
1674        // Pin: amd64 filter never affects arm64.
1675        let mut ctx = nsis_amd64_variant_test_ctx(Some("v9000"));
1676        NsisStage.run(&mut ctx).unwrap();
1677        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
1678        assert_eq!(installers.len(), 1);
1679        assert_eq!(
1680            installers[0].target.as_deref(),
1681            Some("aarch64-pc-windows-msvc")
1682        );
1683    }
1684
1685    /// Core invariant: the `OutFile` literal in the rendered default script
1686    /// equals the recorded `Installer` artifact path — both absolute, both
1687    /// ending in `.exe`. A bare relative `OutFile` would make makensis write
1688    /// into the ephemeral staging dir, and a path mismatch would leave every
1689    /// downstream stage pointing at a file that does not exist.
1690    #[test]
1691    fn test_outfile_equals_recorded_artifact_path() {
1692        use anodizer_core::config::{Config, CrateConfig, NsisConfig};
1693        use anodizer_core::context::{Context, ContextOptions};
1694        use anodizer_core::template::render;
1695
1696        let tmp = tempfile::TempDir::new().unwrap();
1697        let mut config = Config::default();
1698        config.project_name = "myapp".to_string();
1699        config.dist = tmp.path().join("dist");
1700        config.crates = vec![CrateConfig {
1701            name: "myapp".to_string(),
1702            path: ".".to_string(),
1703            tag_template: "v{{ .Version }}".to_string(),
1704            nsis: Some(vec![NsisConfig::default()]),
1705            ..Default::default()
1706        }];
1707
1708        let mut ctx = Context::new(
1709            config,
1710            ContextOptions {
1711                dry_run: true,
1712                ..Default::default()
1713            },
1714        );
1715        ctx.template_vars_mut().set("Version", "1.0.0");
1716        ctx.artifacts.add(Artifact {
1717            kind: ArtifactKind::Binary,
1718            name: String::new(),
1719            path: PathBuf::from("dist/myapp.exe"),
1720            target: Some("x86_64-pc-windows-msvc".to_string()),
1721            crate_name: "myapp".to_string(),
1722            metadata: Default::default(),
1723            size: None,
1724        });
1725
1726        NsisStage.run(&mut ctx).unwrap();
1727        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
1728        assert_eq!(installers.len(), 1);
1729        let recorded = installers[0].path.clone();
1730
1731        // The recorded artifact path is absolute and ends in `.exe`.
1732        assert!(recorded.is_absolute(), "recorded path must be absolute");
1733        assert!(
1734            recorded.to_string_lossy().ends_with(".exe"),
1735            "recorded path must end in .exe, got: {}",
1736            recorded.display()
1737        );
1738
1739        // The stage injects the recorded path as `NsisOutputFile` for the script
1740        // render. Feeding that same value into the default script must yield an
1741        // `OutFile` line equal to the recorded path verbatim.
1742        let recorded_str = recorded.to_string_lossy().into_owned();
1743        let mut vars = ctx.template_vars().clone();
1744        vars.set("NsisOutputFile", &recorded_str);
1745        vars.set("ProgramFiles", "$PROGRAMFILES64");
1746        vars.set("NsisBinaryPath", "staging/myapp.exe");
1747        vars.set("NsisBinaryName", "myapp.exe");
1748        let script = render(default_nsi_script(), &vars).expect("default script must render");
1749        assert!(
1750            script.contains(&format!("OutFile \"{recorded_str}\"")),
1751            "OutFile must equal the recorded artifact path; script:\n{script}"
1752        );
1753    }
1754
1755    /// Under the DEFAULT `dist: ./dist` (relative, never canonicalized by
1756    /// config), the path makensis is told to write — `NsisOutputFile`, derived
1757    /// from the same `exe_path` `absolutize_output_path` produces — must be
1758    /// ABSOLUTE. makensis chdir's to the .nsi script's staging tempdir before
1759    /// resolving a relative `OutFile`, so a relative path would land the
1760    /// installer in that tempdir (which then vanishes). The recorded
1761    /// `Artifact.path` is separately relativized to cwd by the registry for a
1762    /// stable `artifacts.json`; the absolute OutFile resolves to that same
1763    /// cwd-relative location. This case must fail without the cwd-absolutize
1764    /// (the join produces a relative path) and pass with it.
1765    #[test]
1766    fn test_relative_dist_output_path_is_absolute() {
1767        // Exactly the shape the stage builds under default config:
1768        // `dist.join("windows").join(<name>.exe)` with the default relative dist.
1769        let relative = PathBuf::from("./dist")
1770            .join("windows")
1771            .join("myapp_x64_setup.exe");
1772        assert!(
1773            !relative.is_absolute(),
1774            "precondition: the dist-relative path must start out relative"
1775        );
1776
1777        let absolute = absolutize_output_path(relative);
1778        assert!(
1779            absolute.is_absolute(),
1780            "OutFile/NsisOutputFile must be absolute under relative dist, got: {}",
1781            absolute.display()
1782        );
1783        assert!(
1784            absolute.to_string_lossy().ends_with("myapp_x64_setup.exe"),
1785            "absolutize must preserve the rendered name + .exe, got: {}",
1786            absolute.display()
1787        );
1788    }
1789
1790    /// An already-absolute output path passes through `absolutize_output_path`
1791    /// unchanged (canonicalize fails pre-build, so the `is_absolute` branch
1792    /// fires and returns it verbatim).
1793    #[test]
1794    fn test_absolutize_keeps_absolute_path() {
1795        // is_absolute() is host-relative; use a host-absolute input so passthrough is exercised on every platform.
1796        let absolute = if cfg!(windows) {
1797            PathBuf::from(r"C:\dist\windows\myapp_x64_setup.exe")
1798        } else {
1799            PathBuf::from("/dist/windows/myapp_x64_setup.exe")
1800        };
1801        let out = absolutize_output_path(absolute.clone());
1802        assert_eq!(out, absolute);
1803    }
1804
1805    /// End-to-end under relative dist: the recorded `Installer` artifact must
1806    /// still resolve to a file named `myapp_x64_setup.exe`. The registry
1807    /// relativizes the absolute OutFile back to a cwd-relative path for
1808    /// `artifacts.json` stability — both name the same on-disk location.
1809    #[test]
1810    fn test_relative_dist_records_resolvable_path() {
1811        use anodizer_core::config::{Config, CrateConfig, NsisConfig};
1812        use anodizer_core::context::{Context, ContextOptions};
1813
1814        let mut config = Config::default();
1815        config.project_name = "myapp".to_string();
1816        config.dist = PathBuf::from("./dist");
1817        config.crates = vec![CrateConfig {
1818            name: "myapp".to_string(),
1819            path: ".".to_string(),
1820            tag_template: "v{{ .Version }}".to_string(),
1821            nsis: Some(vec![NsisConfig::default()]),
1822            ..Default::default()
1823        }];
1824
1825        let mut ctx = Context::new(
1826            config,
1827            ContextOptions {
1828                dry_run: true,
1829                ..Default::default()
1830            },
1831        );
1832        ctx.template_vars_mut().set("Version", "1.0.0");
1833        ctx.artifacts.add(Artifact {
1834            kind: ArtifactKind::Binary,
1835            name: String::new(),
1836            path: PathBuf::from("dist/myapp.exe"),
1837            target: Some("x86_64-pc-windows-msvc".to_string()),
1838            crate_name: "myapp".to_string(),
1839            metadata: Default::default(),
1840            size: None,
1841        });
1842
1843        NsisStage.run(&mut ctx).unwrap();
1844        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
1845        assert_eq!(installers.len(), 1);
1846        let recorded = installers[0].path.clone();
1847        assert!(
1848            recorded
1849                .file_name()
1850                .and_then(|n| n.to_str())
1851                .is_some_and(|n| n == "myapp_x64_setup.exe"),
1852            "recorded path must name the installer file, got: {}",
1853            recorded.display()
1854        );
1855        // The recorded path resolves under dist/windows/ relative to cwd.
1856        assert!(
1857            recorded.to_string_lossy().contains("dist/windows/")
1858                || recorded.to_string_lossy().contains("dist\\windows\\"),
1859            "recorded path must live under dist/windows/, got: {}",
1860            recorded.display()
1861        );
1862    }
1863
1864    // -------------------------------------------------------------------
1865    // NSIS script template vars
1866    // -------------------------------------------------------------------
1867
1868    /// Render the built-in default script with realistic vars and ensure the
1869    /// output is the expected NSIS snippet (`OutFile`, `InstallDir`, etc.).
1870    /// Pins the default-script render path end-to-end including ProgramFiles
1871    /// (arch-aware) and the absolute `NsisOutputFile` makensis writes to.
1872    #[test]
1873    fn test_default_script_renders_correctly_for_amd64() {
1874        use anodizer_core::template::{TemplateVars, render};
1875
1876        let mut vars = TemplateVars::new();
1877        vars.set("ProjectName", "myapp");
1878        // OutFile must be the absolute artifact path, ending in `.exe`.
1879        vars.set("NsisOutputFile", "/dist/windows/myapp_x64_setup.exe");
1880        vars.set("ProgramFiles", "$PROGRAMFILES64");
1881        vars.set("NsisBinaryPath", "/tmp/staging/myapp.exe");
1882        vars.set("NsisBinaryName", "myapp.exe");
1883        vars.set("Binary", "myapp.exe");
1884        vars.set("Arch", "x64");
1885
1886        let out = render(default_nsi_script(), &vars).expect("default script must render");
1887
1888        assert!(out.contains("Name \"myapp\""));
1889        // OutFile is the absolute NsisOutputFile, never a bare relative filename.
1890        assert!(out.contains("OutFile \"/dist/windows/myapp_x64_setup.exe\""));
1891        assert!(!out.contains("OutFile \"myapp_x64_setup.exe\""));
1892        // 64-bit target lands in PROGRAMFILES64 (not the WOW6432 redirect)
1893        assert!(out.contains("InstallDir \"$PROGRAMFILES64\\myapp\""));
1894        assert!(!out.contains("$PROGRAMFILES\\myapp"));
1895        assert!(out.contains("RequestExecutionLevel admin"));
1896        assert!(out.contains("File \"/tmp/staging/myapp.exe\""));
1897        assert!(out.contains("Delete \"$INSTDIR\\myapp.exe\""));
1898    }
1899
1900    #[test]
1901    fn test_default_script_renders_correctly_for_x86() {
1902        use anodizer_core::template::{TemplateVars, render};
1903
1904        let mut vars = TemplateVars::new();
1905        vars.set("ProjectName", "myapp");
1906        vars.set("NsisOutputFile", "/dist/windows/myapp_x86_setup.exe");
1907        vars.set("ProgramFiles", "$PROGRAMFILES");
1908        vars.set("NsisBinaryPath", "/tmp/staging/myapp.exe");
1909        vars.set("NsisBinaryName", "myapp.exe");
1910        vars.set("Binary", "myapp.exe");
1911        vars.set("Arch", "x86");
1912
1913        let out = render(default_nsi_script(), &vars).expect("default script must render");
1914        assert!(out.contains("OutFile \"/dist/windows/myapp_x86_setup.exe\""));
1915        // 32-bit target uses $PROGRAMFILES
1916        assert!(out.contains("InstallDir \"$PROGRAMFILES\\myapp\""));
1917        assert!(!out.contains("$PROGRAMFILES64"));
1918    }
1919
1920    /// Pin the documented vars (`Name`, `ProgramFiles`, `Binary`, NSIS-native
1921    /// `Arch`) are usable inside a custom user script — pasting an example
1922    /// script must not raise an undefined-variable error.
1923    #[test]
1924    fn test_custom_script_can_use_gr_documented_vars() {
1925        use anodizer_core::config::{Config, CrateConfig, NsisConfig};
1926        use anodizer_core::context::{Context, ContextOptions};
1927
1928        let tmp = tempfile::TempDir::new().unwrap();
1929        let script_path = tmp.path().join("installer.nsi");
1930        // Mirror the shape of the example script: every documented var
1931        // appears at least once.
1932        std::fs::write(
1933            &script_path,
1934            r#"Name "{{ Name }}"
1935OutFile "{{ Name }}.exe"
1936InstallDir "{{ ProgramFiles }}\app"
1937!define ARCH "{{ Arch }}"
1938File "{{ Binary }}"
1939Section
1940SectionEnd
1941"#,
1942        )
1943        .unwrap();
1944
1945        let nsis_cfg = NsisConfig {
1946            script: Some(script_path.to_string_lossy().into_owned()),
1947            ..Default::default()
1948        };
1949
1950        let mut config = Config::default();
1951        config.project_name = "myapp".to_string();
1952        config.dist = tmp.path().join("dist");
1953        std::fs::create_dir_all(&config.dist).unwrap();
1954        config.crates = vec![CrateConfig {
1955            name: "myapp".to_string(),
1956            path: ".".to_string(),
1957            tag_template: "v{{ .Version }}".to_string(),
1958            nsis: Some(vec![nsis_cfg]),
1959            ..Default::default()
1960        }];
1961
1962        let mut ctx = Context::new(
1963            config,
1964            ContextOptions {
1965                dry_run: true,
1966                ..Default::default()
1967            },
1968        );
1969        ctx.template_vars_mut().set("Version", "1.0.0");
1970        ctx.artifacts.add(Artifact {
1971            kind: ArtifactKind::Binary,
1972            name: String::new(),
1973            path: PathBuf::from("dist/myapp.exe"),
1974            target: Some("x86_64-pc-windows-msvc".to_string()),
1975            crate_name: "myapp".to_string(),
1976            metadata: Default::default(),
1977            size: None,
1978        });
1979
1980        // dry-run only renders the name template (not the script body), so to
1981        // exercise the script-render path we drop out of dry-run by writing a
1982        // real `makensis` shim is overkill — instead we directly assert that
1983        // the script's required vars are present in the one-shot context the
1984        // stage builds. This is verified by the lower-level
1985        // `test_default_script_renders_correctly_*` tests above; here we just
1986        // ensure the dry-run path accepts the user script without error.
1987        NsisStage.run(&mut ctx).unwrap();
1988
1989        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
1990        assert_eq!(installers.len(), 1);
1991    }
1992
1993    /// Global template vars must not be polluted by the NSIS-native `Arch`
1994    /// override (which is meant for the script render context only).
1995    #[test]
1996    fn test_nsis_arch_override_does_not_pollute_global_vars() {
1997        use anodizer_core::config::{Config, CrateConfig, NsisConfig};
1998        use anodizer_core::context::{Context, ContextOptions};
1999
2000        let tmp = tempfile::TempDir::new().unwrap();
2001        let mut config = Config::default();
2002        config.project_name = "myapp".to_string();
2003        config.dist = tmp.path().join("dist");
2004        config.crates = vec![CrateConfig {
2005            name: "myapp".to_string(),
2006            path: ".".to_string(),
2007            tag_template: "v{{ .Version }}".to_string(),
2008            nsis: Some(vec![NsisConfig::default()]),
2009            ..Default::default()
2010        }];
2011
2012        let mut ctx = Context::new(
2013            config,
2014            ContextOptions {
2015                dry_run: true,
2016                ..Default::default()
2017            },
2018        );
2019        ctx.template_vars_mut().set("Version", "1.0.0");
2020        ctx.artifacts.add(Artifact {
2021            kind: ArtifactKind::Binary,
2022            name: String::new(),
2023            path: PathBuf::from("dist/myapp.exe"),
2024            target: Some("x86_64-pc-windows-msvc".to_string()),
2025            crate_name: "myapp".to_string(),
2026            metadata: Default::default(),
2027            size: None,
2028        });
2029
2030        NsisStage.run(&mut ctx).unwrap();
2031
2032        // After the stage runs, the global Arch must be back to the Go-style
2033        // value (or cleared) — NSIS-native `x64` must not leak out.
2034        let global_arch = ctx.template_vars().get("Arch").cloned();
2035        assert!(
2036            global_arch.as_deref() != Some("x64"),
2037            "NSIS-native Arch must not leak into global vars, got: {global_arch:?}"
2038        );
2039    }
2040
2041    // -------------------------------------------------------------------
2042    // extra_files glob with name_template — multi-match bail
2043    // -------------------------------------------------------------------
2044
2045    /// When a glob in `extra_files` matches multiple files and a constant
2046    /// `name_template` is set, the stage must bail rather than silently
2047    /// overwrite every file to the same destination name.
2048    #[test]
2049    fn test_extra_files_multi_match_with_name_template_bails() {
2050        use anodizer_core::config::ExtraFileSpec;
2051        use anodizer_core::config::{Config, CrateConfig, NsisConfig};
2052        use anodizer_core::context::{Context, ContextOptions};
2053
2054        let tmp = tempfile::TempDir::new().unwrap();
2055        let extra_dir = tmp.path().join("extras");
2056        std::fs::create_dir_all(&extra_dir).unwrap();
2057        std::fs::write(extra_dir.join("a.txt"), "a").unwrap();
2058        std::fs::write(extra_dir.join("b.txt"), "b").unwrap();
2059
2060        let glob_pattern = format!("{}/*.txt", extra_dir.display());
2061        let nsis_cfg = NsisConfig {
2062            extra_files: Some(vec![ExtraFileSpec::Detailed {
2063                glob: glob_pattern,
2064                name_template: Some("renamed.txt".to_string()),
2065                allow_empty: false,
2066            }]),
2067            ..Default::default()
2068        };
2069
2070        let script_path = tmp.path().join("installer.nsi");
2071        std::fs::write(&script_path, "Section\nSectionEnd\n").unwrap();
2072        let nsis_cfg = NsisConfig {
2073            script: Some(script_path.to_string_lossy().into_owned()),
2074            ..nsis_cfg
2075        };
2076
2077        let mut config = Config::default();
2078        config.project_name = "myapp".to_string();
2079        config.dist = tmp.path().join("dist");
2080        std::fs::create_dir_all(&config.dist).unwrap();
2081        config.crates = vec![CrateConfig {
2082            name: "myapp".to_string(),
2083            path: ".".to_string(),
2084            tag_template: "v{{ .Version }}".to_string(),
2085            nsis: Some(vec![nsis_cfg]),
2086            ..Default::default()
2087        }];
2088
2089        let mut ctx = Context::new(config, ContextOptions::default());
2090        ctx.template_vars_mut().set("Version", "1.0.0");
2091
2092        // Write a fake binary file so the stage actually reaches extra_files.
2093        let bin_path = tmp.path().join("myapp.exe");
2094        std::fs::write(&bin_path, b"binary").unwrap();
2095        ctx.artifacts.add(Artifact {
2096            kind: ArtifactKind::Binary,
2097            name: String::new(),
2098            path: bin_path,
2099            target: Some("x86_64-pc-windows-msvc".to_string()),
2100            crate_name: "myapp".to_string(),
2101            metadata: Default::default(),
2102            size: None,
2103        });
2104
2105        // Stage will bail on extra_files before reaching makensis. The bail
2106        // is what we're asserting, so the makensis-missing path is irrelevant.
2107        let err = NsisStage
2108            .run(&mut ctx)
2109            .expect_err("multi-match glob + name_template must bail");
2110        let msg = format!("{err:#}");
2111        assert!(
2112            msg.contains("name_template") && msg.contains("exactly one"),
2113            "error must reference the name_template constraint, got: {msg}"
2114        );
2115    }
2116
2117    /// Single-match glob with `name_template` is the supported case — must
2118    /// not bail.
2119    #[test]
2120    fn test_extra_files_single_match_with_name_template_ok() {
2121        use anodizer_core::config::ExtraFileSpec;
2122        use anodizer_core::config::{Config, CrateConfig, NsisConfig};
2123        use anodizer_core::context::{Context, ContextOptions};
2124
2125        let tmp = tempfile::TempDir::new().unwrap();
2126        let extra_dir = tmp.path().join("extras");
2127        std::fs::create_dir_all(&extra_dir).unwrap();
2128        std::fs::write(extra_dir.join("only.txt"), "x").unwrap();
2129        let glob_pattern = format!("{}/only.txt", extra_dir.display());
2130
2131        let nsis_cfg = NsisConfig {
2132            extra_files: Some(vec![ExtraFileSpec::Detailed {
2133                glob: glob_pattern,
2134                name_template: Some("renamed.txt".to_string()),
2135                allow_empty: false,
2136            }]),
2137            ..Default::default()
2138        };
2139
2140        let mut config = Config::default();
2141        config.project_name = "myapp".to_string();
2142        config.dist = tmp.path().join("dist");
2143        config.crates = vec![CrateConfig {
2144            name: "myapp".to_string(),
2145            path: ".".to_string(),
2146            tag_template: "v{{ .Version }}".to_string(),
2147            nsis: Some(vec![nsis_cfg]),
2148            ..Default::default()
2149        }];
2150
2151        let mut ctx = Context::new(
2152            config,
2153            ContextOptions {
2154                dry_run: true,
2155                ..Default::default()
2156            },
2157        );
2158        ctx.template_vars_mut().set("Version", "1.0.0");
2159        ctx.artifacts.add(Artifact {
2160            kind: ArtifactKind::Binary,
2161            name: String::new(),
2162            path: PathBuf::from("dist/myapp.exe"),
2163            target: Some("x86_64-pc-windows-msvc".to_string()),
2164            crate_name: "myapp".to_string(),
2165            metadata: Default::default(),
2166            size: None,
2167        });
2168
2169        // dry-run does not exercise the extra_files copy loop, but multi-match
2170        // bail logic is exercised by the prior test. Here we just assert that
2171        // a single-match glob with name_template doesn't trigger any error.
2172        NsisStage.run(&mut ctx).unwrap();
2173    }
2174}