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
12pub 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
50pub fn nsis_command(script_path: &str) -> Vec<String> {
58 vec!["makensis".to_string(), script_path.to_string()]
59}
60
61fn 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
83pub struct NsisStage;
88
89fn os_arch_from_target(target: Option<&str>) -> (String, String) {
91 anodizer_core::target::os_arch_with_default(target, "windows")
92}
93
94pub(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
107pub(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
120const 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 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 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 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 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 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 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 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 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 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 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 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 let (os, arch) = os_arch_from_target(target.as_deref());
309
310 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 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 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 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 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 let output_dir = dist.join("windows");
366 let exe_path = output_dir.join(&exe_filename);
367
368 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 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 fs::create_dir_all(&output_dir).with_context(|| {
419 format!("create NSIS output dir: {}", output_dir.display())
420 })?;
421
422 let staging_tmp =
424 tempfile::tempdir().context("create temp dir for NSIS staging")?;
425 let staging_dir = staging_tmp.path();
426
427 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 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 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 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 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 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 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 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 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 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 if !archives_to_remove.is_empty() {
599 ctx.artifacts.remove_by_paths(&archives_to_remove);
600 }
601
602 for artifact in new_artifacts {
604 ctx.artifacts.add(artifact);
605 }
606
607 Ok(())
608 }
609}
610
611pub 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 #[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 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 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 #[test]
751 fn test_default_name_template_uses_nsis_arch() {
752 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 assert!(
795 path.ends_with("myapp_x64_setup.exe"),
796 "expected NSIS-native arch + .exe in filename, got: {path}"
797 );
798 }
799
800 #[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 #[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 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 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 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 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 let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
989 assert_eq!(installers.len(), 2);
990
991 for inst in &installers {
993 assert_eq!(inst.metadata.get("format").unwrap(), "nsis");
994 assert_eq!(inst.kind, ArtifactKind::Installer);
995 }
996
997 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 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 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 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 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 let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
1136 assert_eq!(installers.len(), 1);
1137
1138 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 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 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 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 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 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 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 #[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 #[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 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 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 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 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 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 #[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 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 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 #[test]
1766 fn test_relative_dist_output_path_is_absolute() {
1767 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 #[test]
1794 fn test_absolutize_keeps_absolute_path() {
1795 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 #[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 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 #[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 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 assert!(out.contains("OutFile \"/dist/windows/myapp_x64_setup.exe\""));
1891 assert!(!out.contains("OutFile \"myapp_x64_setup.exe\""));
1892 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 assert!(out.contains("InstallDir \"$PROGRAMFILES\\myapp\""));
1917 assert!(!out.contains("$PROGRAMFILES64"));
1918 }
1919
1920 #[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 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 NsisStage.run(&mut ctx).unwrap();
1988
1989 let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
1990 assert_eq!(installers.len(), 1);
1991 }
1992
1993 #[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 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 #[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 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 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 #[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 NsisStage.run(&mut ctx).unwrap();
2173 }
2174}