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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum DmgTool {
19 Hdiutil,
21 Genisoimage,
23 Mkisofs,
25}
26
27pub fn dmg_tool() -> Option<DmgTool> {
32 if anodizer_core::util::find_binary("hdiutil") {
33 Some(DmgTool::Hdiutil)
34 } else if anodizer_core::util::find_binary("genisoimage") {
35 Some(DmgTool::Genisoimage)
36 } else if anodizer_core::util::find_binary("mkisofs") {
37 Some(DmgTool::Mkisofs)
38 } else {
39 None
40 }
41}
42
43pub fn dmg_command(
54 tool: DmgTool,
55 vol_name: &str,
56 staging_dir: &str,
57 output_path: &str,
58) -> Vec<String> {
59 match tool {
60 DmgTool::Hdiutil => vec![
61 "hdiutil".to_string(),
62 "create".to_string(),
63 "-volname".to_string(),
64 vol_name.to_string(),
65 "-srcfolder".to_string(),
66 staging_dir.to_string(),
67 "-ov".to_string(),
68 "-format".to_string(),
69 "UDZO".to_string(),
70 output_path.to_string(),
71 ],
72 DmgTool::Genisoimage => vec![
73 "genisoimage".to_string(),
74 "-V".to_string(),
75 vol_name.to_string(),
76 "-D".to_string(),
77 "-R".to_string(),
78 "-apple".to_string(),
79 "-no-pad".to_string(),
80 "-o".to_string(),
81 output_path.to_string(),
82 staging_dir.to_string(),
83 ],
84 DmgTool::Mkisofs => vec![
85 "mkisofs".to_string(),
86 "-V".to_string(),
87 vol_name.to_string(),
88 "-D".to_string(),
89 "-R".to_string(),
90 "-apple".to_string(),
91 "-no-pad".to_string(),
92 "-o".to_string(),
93 output_path.to_string(),
94 staging_dir.to_string(),
95 ],
96 }
97}
98
99pub struct DmgStage;
104
105fn os_arch_from_target(target: Option<&str>) -> (String, String) {
107 anodizer_core::target::os_arch_with_default(target, "darwin")
108}
109
110const DEFAULT_NAME_TEMPLATE: &str = "{{ ProjectName }}_{{ Arch }}";
115
116pub(crate) fn stage_binary_into(
129 staging_dir: &std::path::Path,
130 binary_path: &std::path::Path,
131 binary_name: &str,
132 use_mode: &str,
133) -> Result<std::path::PathBuf> {
134 let staged_binary = staging_dir.join(binary_name);
135 if use_mode == "appbundle" {
136 anodizer_core::util::copy_dir_tree(binary_path, &staged_binary)
137 .with_context(|| format!("copy app bundle {} to staging dir", binary_path.display()))?;
138 return Ok(staged_binary);
139 }
140 std::fs::copy(binary_path, &staged_binary)
141 .with_context(|| format!("copy binary {} to staging dir", binary_path.display()))?;
142 #[cfg(unix)]
143 {
144 use std::os::unix::fs::PermissionsExt;
145 let perms = std::fs::Permissions::from_mode(0o755);
146 std::fs::set_permissions(&staged_binary, perms).with_context(|| {
147 format!(
148 "dmg: set executable permission on {}",
149 staged_binary.display()
150 )
151 })?;
152 }
153 Ok(staged_binary)
154}
155
156#[cfg(unix)]
164pub(crate) fn maybe_create_applications_symlink(
165 staging_dir: &std::path::Path,
166 use_mode: &str,
167) -> Result<()> {
168 if use_mode != "appbundle" {
169 return Ok(());
170 }
171 let link_path = staging_dir.join("Applications");
172 if link_path.symlink_metadata().is_ok() {
173 return Ok(());
174 }
175 std::os::unix::fs::symlink("/Applications", &link_path).with_context(|| {
176 format!(
177 "dmg: create /Applications symlink at {}",
178 link_path.display()
179 )
180 })
181}
182
183pub(crate) fn resolve_volume_name(
186 ctx: &Context,
187 dmg_cfg: &anodizer_core::config::DmgConfig,
188 project_name: &str,
189) -> Result<String> {
190 match &dmg_cfg.volume_name {
191 Some(tmpl) => ctx
192 .render_template(tmpl)
193 .with_context(|| "dmg: render volume_name template"),
194 None => Ok(project_name.to_string()),
195 }
196}
197
198pub(crate) fn resolve_mod_timestamp(ctx: &Context, tmpl: &str) -> Result<String> {
201 ctx.render_template(tmpl)
202 .with_context(|| "dmg: render mod_timestamp template")
203}
204
205impl Stage for DmgStage {
206 fn name(&self) -> &str {
207 "dmg"
208 }
209
210 fn run(&self, ctx: &mut Context) -> Result<()> {
211 let log = ctx.logger("dmg");
212 let selected = ctx.options.selected_crates.clone();
213 let dry_run = ctx.options.dry_run;
214 let dist = ctx.config.dist.clone();
215
216 let crates: Vec<_> = ctx
218 .config
219 .crates
220 .iter()
221 .filter(|c| selected.is_empty() || selected.contains(&c.name))
222 .filter(|c| c.dmgs.is_some())
223 .cloned()
224 .collect();
225
226 if crates.is_empty() {
227 return Ok(());
228 }
229
230 let project_name = ctx.config.project_name.clone();
231
232 let multi_crate = crates.len() > 1;
239 let original_project_name = ctx
240 .template_vars()
241 .get("ProjectName")
242 .cloned()
243 .unwrap_or_else(|| project_name.clone());
244
245 let mut new_artifacts: Vec<Artifact> = Vec::new();
246 let mut archives_to_remove: Vec<PathBuf> = Vec::new();
247
248 let loop_result: Result<()> = (|| {
252 for krate in &crates {
253 let Some(dmgs) = krate.dmgs.as_ref() else {
254 continue;
255 };
256 if multi_crate {
257 ctx.template_vars_mut().set("ProjectName", &krate.name);
258 }
259 let crate_project_name = if multi_crate {
263 krate.name.clone()
264 } else {
265 project_name.clone()
266 };
267 for dmg_cfg in dmgs {
268 let dmg_id_for_log = dmg_cfg.id.as_deref().unwrap_or("default").to_string();
269
270 let proceed = anodizer_core::config::evaluate_if_condition(
275 dmg_cfg.if_condition.as_deref(),
276 &format!("dmg config '{}' for crate '{}'", dmg_id_for_log, krate.name),
277 |t| ctx.render_template(t),
278 )?;
279 if !proceed {
280 log.status(&format!(
281 "skipped dmg config '{}' for crate {} — `if` condition evaluated falsy",
282 dmg_id_for_log, krate.name
283 ));
284 continue;
285 }
286
287 if let Some(ref d) = dmg_cfg.skip {
289 let off = d
290 .try_evaluates_to_true(|s| ctx.render_template(s))
291 .with_context(|| {
292 format!("dmg: render skip template for crate {}", krate.name)
293 })?;
294 if off {
295 log.status(&format!("dmg config skipped for crate {}", krate.name));
296 continue;
297 }
298 }
299
300 let use_mode = dmg_cfg.use_.as_deref().unwrap_or("binary");
302 if use_mode != "binary" && use_mode != "appbundle" {
303 anyhow::bail!(
304 "dmg: invalid `use` value '{}' for crate '{}'; expected 'binary' or 'appbundle'",
305 use_mode,
306 krate.name
307 );
308 }
309
310 if let Some(extra_files) = &dmg_cfg.extra_files {
317 anodizer_core::extrafiles::resolve(extra_files, &log)
318 .context("dmg: validate extra_files")?;
319 }
320
321 let source_artifacts: Vec<Artifact> = if use_mode == "appbundle" {
323 ctx.artifacts
325 .by_kind_and_crate(ArtifactKind::Installer, &krate.name)
326 .into_iter()
327 .filter(|a| {
328 a.metadata
329 .get("format")
330 .map(|f| f == "appbundle")
331 .unwrap_or(false)
332 })
333 .cloned()
334 .collect()
335 } else {
336 ctx.artifacts
338 .by_kind_and_crate(ArtifactKind::Binary, &krate.name)
339 .into_iter()
340 .filter(|b| {
341 b.target
342 .as_deref()
343 .map(anodizer_core::target::is_darwin)
344 .unwrap_or(false)
345 })
346 .cloned()
347 .collect()
348 };
349
350 let mut filtered = source_artifacts.clone();
352 if let Some(ref filter_ids) = dmg_cfg.ids
353 && !filter_ids.is_empty()
354 {
355 filtered.retain(|b| {
356 b.metadata
357 .get("id")
358 .map(|id| filter_ids.contains(id))
359 .unwrap_or(false)
360 || b.metadata
361 .get("name")
362 .map(|n| filter_ids.contains(n))
363 .unwrap_or(false)
364 });
365 }
366
367 if let Some(ref want) = dmg_cfg.amd64_variant {
372 filtered.retain(|b| {
373 let target = b.target.as_deref().unwrap_or("");
374 let (_, arch) = anodizer_core::target::map_target(target);
375 if arch != "amd64" {
376 return true;
377 }
378 b.metadata
379 .get("amd64_variant")
380 .map(String::as_str)
381 .unwrap_or("v1")
382 == want
383 });
384 }
385
386 if filtered.is_empty() && source_artifacts.is_empty() {
388 let msg = if use_mode == "appbundle" {
389 format!(
390 "skipped DMG generation for crate '{}' — no appbundle artifacts \
391 found (expected Installer artifacts with format=appbundle)",
392 krate.name
393 )
394 } else {
395 format!(
396 "skipped DMG generation for crate '{}' — no macOS binary \
397 artifacts found (expected binaries targeting darwin/apple)",
398 krate.name
399 )
400 };
401 log.skip_line(ctx.options.show_skipped, &msg);
402 continue;
403 }
404 if filtered.is_empty() {
405 log.warn(&format!(
406 "skipped dmg for crate '{}' — ids filter {:?} matched no artifacts",
407 krate.name, dmg_cfg.ids
408 ));
409 continue;
410 }
411
412 let mut by_target: std::collections::BTreeMap<Option<String>, Vec<PathBuf>> =
417 std::collections::BTreeMap::new();
418 for b in &filtered {
419 by_target
420 .entry(b.target.clone())
421 .or_default()
422 .push(b.path.clone());
423 }
424
425 for (target, binary_paths) in &by_target {
426 let (os, arch) = os_arch_from_target(target.as_deref());
428
429 ctx.template_vars_mut().set("Os", &os);
431 ctx.template_vars_mut().set("Arch", &arch);
432 ctx.template_vars_mut()
433 .set("Target", target.as_deref().unwrap_or(""));
434
435 let name_template =
437 dmg_cfg.name.as_deref().unwrap_or(DEFAULT_NAME_TEMPLATE);
438
439 let dmg_filename =
440 ctx.render_template(name_template).with_context(|| {
441 format!(
442 "dmg: render name template for crate {} target {:?}",
443 krate.name, target
444 )
445 })?;
446
447 let dmg_filename = if dmg_filename.to_lowercase().ends_with(".dmg") {
449 dmg_filename
450 } else {
451 format!("{dmg_filename}.dmg")
452 };
453
454 let output_dir = dist.join("macos");
456 let dmg_path = output_dir.join(&dmg_filename);
457
458 let vol_name = resolve_volume_name(ctx, dmg_cfg, &crate_project_name)?;
459
460 let staged: Vec<(&std::path::PathBuf, String)> = binary_paths
465 .iter()
466 .map(|p| {
467 let name = p
468 .file_name()
469 .and_then(|n| n.to_str())
470 .unwrap_or(&krate.name)
471 .to_string();
472 (p, name)
473 })
474 .collect();
475
476 {
480 let mut staged_names: std::collections::HashSet<&str> =
481 std::collections::HashSet::new();
482 for (_, binary_name) in &staged {
483 if !staged_names.insert(binary_name.as_str()) {
484 anyhow::bail!(
485 "dmg: duplicate filename '{}' in staging dir for crate \
486 '{}' target {:?}; two source binaries resolve to the \
487 same name",
488 binary_name,
489 krate.name,
490 target
491 );
492 }
493 }
494 }
495
496 if dry_run {
497 log.status(&format!(
498 "(dry-run) would create DMG {} for crate {} target {:?}",
499 dmg_filename, krate.name, target
500 ));
501
502 new_artifacts.push(Artifact {
503 kind: ArtifactKind::DiskImage,
504 name: String::new(),
505 path: dmg_path,
506 target: target.clone(),
507 crate_name: krate.name.clone(),
508 metadata: {
509 let mut m =
510 HashMap::from([("format".to_string(), "dmg".to_string())]);
511 if let Some(id) = &dmg_cfg.id {
512 m.insert("id".to_string(), id.clone());
513 }
514 m
515 },
516 size: None,
517 });
518
519 archives_to_remove.extend(anodizer_core::util::collect_if_replace(
521 dmg_cfg.replace,
522 &ctx.artifacts,
523 &krate.name,
524 target.as_deref(),
525 ));
526
527 continue;
528 }
529
530 let tool = dmg_tool().ok_or_else(|| {
532 anyhow::anyhow!(
533 "no DMG creation tool found (need hdiutil, genisoimage, or mkisofs)"
534 )
535 })?;
536
537 fs::create_dir_all(&output_dir).with_context(|| {
539 format!("create dmg output dir: {}", output_dir.display())
540 })?;
541
542 let staging_tmp =
544 tempfile::tempdir().context("create temp dir for dmg staging")?;
545 let staging_dir = staging_tmp.path();
546
547 for (binary_path, binary_name) in &staged {
550 stage_binary_into(staging_dir, binary_path, binary_name, use_mode)?;
551 }
552
553 #[cfg(unix)]
554 maybe_create_applications_symlink(staging_dir, use_mode)?;
555
556 if let Some(extra_files) = &dmg_cfg.extra_files {
560 let resolved = anodizer_core::extrafiles::resolve(extra_files, &log)
561 .context("dmg: resolve extra_files")?;
562 for rf in resolved {
563 let dst_name = rf
564 .name_template
565 .or_else(|| {
566 rf.path
567 .file_name()
568 .and_then(|n| n.to_str())
569 .map(|s| s.to_string())
570 })
571 .unwrap_or_else(|| "extra".to_string());
572 let dst = staging_dir.join(&dst_name);
573 fs::copy(&rf.path, &dst).with_context(|| {
574 format!("copy extra file {} to staging dir", rf.path.display())
575 })?;
576 }
577 }
578
579 if let Some(ref tpl_specs) = dmg_cfg.templated_extra_files
581 && !tpl_specs.is_empty()
582 {
583 anodizer_core::templated_files::process_templated_extra_files(
584 tpl_specs,
585 ctx,
586 staging_dir,
587 "dmg",
588 )?;
589 }
590
591 if let Some(ref ts_tmpl) = dmg_cfg.mod_timestamp {
592 let ts = resolve_mod_timestamp(ctx, ts_tmpl)?;
593 anodizer_core::util::apply_mod_timestamp(staging_dir, &ts, &log)?;
594 }
595
596 if tool == DmgTool::Hdiutil {
600 let mount_path = format!("/Volumes/{vol_name}");
601 let detach = Command::new("hdiutil")
602 .args(["detach", "-force", &mount_path])
603 .output();
604 if let Ok(out) = detach
605 && out.status.success()
606 {
607 log.verbose(&format!("detached stale mount at {mount_path}"));
608 }
609 }
610
611 let cmd_args = dmg_command(
613 tool,
614 &vol_name,
615 &staging_dir.to_string_lossy(),
616 &dmg_path.to_string_lossy(),
617 );
618
619 log.verbose(&format!("running {}", cmd_args.join(" ")));
620
621 let output = Command::new(&cmd_args[0])
622 .args(&cmd_args[1..])
623 .output()
624 .with_context(|| {
625 format!(
626 "execute dmg tool for crate {} target {:?}",
627 krate.name, target
628 )
629 })?;
630 log.check_output(output, "dmg")?;
631
632 log.status(&format!(
633 "built DMG {}",
634 dmg_path
635 .file_name()
636 .map(|n| n.to_string_lossy().into_owned())
637 .unwrap_or_else(|| dmg_path.to_string_lossy().into_owned())
638 ));
639
640 new_artifacts.push(Artifact {
641 kind: ArtifactKind::DiskImage,
642 name: String::new(),
643 path: dmg_path,
644 target: target.clone(),
645 crate_name: krate.name.clone(),
646 metadata: {
647 let mut m =
648 HashMap::from([("format".to_string(), "dmg".to_string())]);
649 if let Some(id) = &dmg_cfg.id {
650 m.insert("id".to_string(), id.clone());
651 }
652 m
653 },
654 size: None,
655 });
656
657 archives_to_remove.extend(anodizer_core::util::collect_if_replace(
659 dmg_cfg.replace,
660 &ctx.artifacts,
661 &krate.name,
662 target.as_deref(),
663 ));
664 }
665 }
666 }
667 Ok(())
668 })();
669
670 if multi_crate {
671 ctx.template_vars_mut()
672 .set("ProjectName", &original_project_name);
673 }
674 loop_result?;
675
676 anodizer_core::template::clear_per_target_vars(ctx.template_vars_mut());
677
678 if !archives_to_remove.is_empty() {
680 ctx.artifacts.remove_by_paths(&archives_to_remove);
681 }
682
683 for artifact in new_artifacts {
685 ctx.artifacts.add(artifact);
686 }
687
688 Ok(())
689 }
690}
691
692pub fn env_requirements(
701 ctx: &anodizer_core::context::Context,
702) -> Vec<anodizer_core::EnvRequirement> {
703 if !anodizer_core::env_preflight::configured_build_targets(ctx)
704 .iter()
705 .any(|t| anodizer_core::target::is_darwin(t))
706 {
707 return Vec::new();
708 }
709 let configured = anodizer_core::env_preflight::crate_universe(&ctx.config)
710 .into_iter()
711 .flat_map(|c| c.dmgs.iter().flatten())
712 .any(|cfg| {
713 !anodizer_core::env_preflight::entry_inactive(
714 ctx,
715 cfg.skip.as_ref(),
716 None,
717 cfg.if_condition.as_deref(),
718 )
719 });
720 if !configured {
721 return Vec::new();
722 }
723 vec![anodizer_core::EnvRequirement::ToolAnyOf {
724 names: vec![
725 "hdiutil".to_string(),
726 "genisoimage".to_string(),
727 "mkisofs".to_string(),
728 ],
729 }]
730}
731
732#[cfg(test)]
733#[allow(clippy::field_reassign_with_default)]
734mod tests {
735 use super::*;
736
737 #[test]
738 fn test_dmg_tool_detection() {
739 let result = dmg_tool();
742 match result {
743 Some(DmgTool::Hdiutil) => assert_eq!(result, Some(DmgTool::Hdiutil)),
744 Some(DmgTool::Genisoimage) => assert_eq!(result, Some(DmgTool::Genisoimage)),
745 Some(DmgTool::Mkisofs) => assert_eq!(result, Some(DmgTool::Mkisofs)),
746 None => assert!(result.is_none()),
747 }
748 }
749
750 #[test]
751 fn test_dmg_command_hdiutil() {
752 let cmd = dmg_command(DmgTool::Hdiutil, "MyApp", "/tmp/staging", "/tmp/out.dmg");
753 assert_eq!(
754 cmd,
755 vec![
756 "hdiutil",
757 "create",
758 "-volname",
759 "MyApp",
760 "-srcfolder",
761 "/tmp/staging",
762 "-ov",
763 "-format",
764 "UDZO",
765 "/tmp/out.dmg",
766 ]
767 );
768 }
769
770 #[test]
771 fn test_dmg_command_genisoimage() {
772 let cmd = dmg_command(
773 DmgTool::Genisoimage,
774 "MyApp",
775 "/tmp/staging",
776 "/tmp/out.dmg",
777 );
778 assert_eq!(
779 cmd,
780 vec![
781 "genisoimage",
782 "-V",
783 "MyApp",
784 "-D",
785 "-R",
786 "-apple",
787 "-no-pad",
788 "-o",
789 "/tmp/out.dmg",
790 "/tmp/staging",
791 ]
792 );
793 }
794
795 #[test]
796 fn test_dmg_command_mkisofs() {
797 let cmd = dmg_command(DmgTool::Mkisofs, "MyApp", "/tmp/staging", "/tmp/out.dmg");
798 assert_eq!(
799 cmd,
800 vec![
801 "mkisofs",
802 "-V",
803 "MyApp",
804 "-D",
805 "-R",
806 "-apple",
807 "-no-pad",
808 "-o",
809 "/tmp/out.dmg",
810 "/tmp/staging",
811 ]
812 );
813 }
814
815 #[test]
816 fn test_stage_skips_when_no_dmg_config() {
817 use anodizer_core::config::Config;
818 use anodizer_core::context::{Context, ContextOptions};
819
820 let config = Config::default();
822 let mut ctx = Context::new(config, ContextOptions::default());
823 let stage = DmgStage;
824 assert!(stage.run(&mut ctx).is_ok());
825 assert!(ctx.artifacts.all().is_empty());
826 }
827
828 #[test]
829 fn test_stage_skips_when_disabled() {
830 use anodizer_core::config::{Config, CrateConfig, DmgConfig, StringOrBool};
831 use anodizer_core::context::{Context, ContextOptions};
832
833 let dmg_cfg = DmgConfig {
834 skip: Some(StringOrBool::Bool(true)),
835 ..Default::default()
836 };
837
838 let crate_cfg = CrateConfig {
839 name: "myapp".to_string(),
840 path: ".".to_string(),
841 tag_template: "v{{ .Version }}".to_string(),
842 dmgs: Some(vec![dmg_cfg]),
843 ..Default::default()
844 };
845
846 let mut config = Config::default();
847 config.project_name = "myapp".to_string();
848 config.crates = vec![crate_cfg];
849
850 let mut ctx = Context::new(
851 config,
852 ContextOptions {
853 dry_run: true,
854 ..Default::default()
855 },
856 );
857 ctx.template_vars_mut().set("Version", "1.0.0");
858
859 ctx.artifacts.add(Artifact {
861 kind: ArtifactKind::Binary,
862 name: String::new(),
863 path: PathBuf::from("dist/myapp"),
864 target: Some("aarch64-apple-darwin".to_string()),
865 crate_name: "myapp".to_string(),
866 metadata: Default::default(),
867 size: None,
868 });
869
870 let stage = DmgStage;
871 stage.run(&mut ctx).unwrap();
872
873 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
875 assert!(dmgs.is_empty());
876 }
877
878 #[test]
879 fn test_stage_dry_run_registers_artifacts() {
880 use anodizer_core::config::{Config, CrateConfig, DmgConfig};
881 use anodizer_core::context::{Context, ContextOptions};
882
883 let tmp = tempfile::TempDir::new().unwrap();
884
885 let dmg_cfg = DmgConfig::default();
886
887 let crate_cfg = CrateConfig {
888 name: "myapp".to_string(),
889 path: ".".to_string(),
890 tag_template: "v{{ .Version }}".to_string(),
891 dmgs: Some(vec![dmg_cfg]),
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![crate_cfg];
899
900 let mut ctx = Context::new(
901 config,
902 ContextOptions {
903 dry_run: true,
904 ..Default::default()
905 },
906 );
907 ctx.template_vars_mut().set("Version", "1.0.0");
908
909 ctx.artifacts.add(Artifact {
911 kind: ArtifactKind::Binary,
912 name: String::new(),
913 path: PathBuf::from("dist/myapp"),
914 target: Some("aarch64-apple-darwin".to_string()),
915 crate_name: "myapp".to_string(),
916 metadata: Default::default(),
917 size: None,
918 });
919 ctx.artifacts.add(Artifact {
920 kind: ArtifactKind::Binary,
921 name: String::new(),
922 path: PathBuf::from("dist/myapp_x86"),
923 target: Some("x86_64-apple-darwin".to_string()),
924 crate_name: "myapp".to_string(),
925 metadata: Default::default(),
926 size: None,
927 });
928
929 let stage = DmgStage;
930 stage.run(&mut ctx).unwrap();
931
932 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
934 assert_eq!(dmgs.len(), 2);
935
936 for dmg in &dmgs {
938 assert_eq!(dmg.metadata.get("format").unwrap(), "dmg");
939 assert_eq!(dmg.kind, ArtifactKind::DiskImage);
940 }
941
942 let targets: Vec<&str> = dmgs.iter().map(|a| a.target.as_deref().unwrap()).collect();
944 assert!(targets.contains(&"aarch64-apple-darwin"));
945 assert!(targets.contains(&"x86_64-apple-darwin"));
946 }
947
948 #[test]
949 fn test_workspace_per_crate_distinct_filenames() {
950 use anodizer_core::config::{Config, CrateConfig, DmgConfig};
951 use anodizer_core::context::{Context, ContextOptions};
952
953 let tmp = tempfile::TempDir::new().unwrap();
954
955 let make_crate = |name: &str| CrateConfig {
960 name: name.to_string(),
961 path: ".".to_string(),
962 tag_template: "v{{ .Version }}".to_string(),
963 dmgs: Some(vec![DmgConfig::default()]),
964 ..Default::default()
965 };
966
967 let mut config = Config::default();
968 config.project_name = "workspace".to_string();
969 config.dist = tmp.path().join("dist");
970 config.crates = vec![make_crate("alpha"), make_crate("beta")];
971
972 let mut ctx = Context::new(
973 config,
974 ContextOptions {
975 dry_run: true,
976 ..Default::default()
977 },
978 );
979 ctx.template_vars_mut().set("Version", "1.0.0");
980
981 for crate_name in ["alpha", "beta"] {
982 ctx.artifacts.add(Artifact {
983 kind: ArtifactKind::Binary,
984 name: String::new(),
985 path: PathBuf::from(format!("dist/{crate_name}")),
986 target: Some("aarch64-apple-darwin".to_string()),
987 crate_name: crate_name.to_string(),
988 metadata: Default::default(),
989 size: None,
990 });
991 }
992
993 let stage = DmgStage;
994 stage.run(&mut ctx).unwrap();
995
996 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
997 assert_eq!(dmgs.len(), 2, "expected one DMG per crate");
998
999 let filenames: Vec<String> = dmgs
1000 .iter()
1001 .map(|a| a.path.file_name().unwrap().to_string_lossy().into_owned())
1002 .collect();
1003
1004 assert!(
1005 filenames.iter().any(|f| f.contains("alpha")),
1006 "no DMG filename contains crate name 'alpha': {filenames:?}"
1007 );
1008 assert!(
1009 filenames.iter().any(|f| f.contains("beta")),
1010 "no DMG filename contains crate name 'beta': {filenames:?}"
1011 );
1012 assert_ne!(
1013 filenames[0], filenames[1],
1014 "the two crates' DMGs must not share a filename (clobber): {filenames:?}"
1015 );
1016
1017 assert_eq!(
1020 ctx.template_vars().get("ProjectName").map(String::as_str),
1021 Some("workspace"),
1022 "ProjectName not restored after per-crate rebind"
1023 );
1024 }
1025
1026 #[test]
1027 fn test_stage_dry_run_with_name_template() {
1028 use anodizer_core::config::{Config, CrateConfig, DmgConfig};
1029 use anodizer_core::context::{Context, ContextOptions};
1030
1031 let tmp = tempfile::TempDir::new().unwrap();
1032
1033 let dmg_cfg = DmgConfig {
1034 name: Some("{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}.dmg".to_string()),
1035 ..Default::default()
1036 };
1037
1038 let crate_cfg = CrateConfig {
1039 name: "myapp".to_string(),
1040 path: ".".to_string(),
1041 tag_template: "v{{ .Version }}".to_string(),
1042 dmgs: Some(vec![dmg_cfg]),
1043 ..Default::default()
1044 };
1045
1046 let mut config = Config::default();
1047 config.project_name = "myapp".to_string();
1048 config.dist = tmp.path().join("dist");
1049 config.crates = vec![crate_cfg];
1050
1051 let mut ctx = Context::new(
1052 config,
1053 ContextOptions {
1054 dry_run: true,
1055 ..Default::default()
1056 },
1057 );
1058 ctx.template_vars_mut().set("Version", "2.0.0");
1059
1060 ctx.artifacts.add(Artifact {
1061 kind: ArtifactKind::Binary,
1062 name: String::new(),
1063 path: PathBuf::from("dist/myapp"),
1064 target: Some("aarch64-apple-darwin".to_string()),
1065 crate_name: "myapp".to_string(),
1066 metadata: Default::default(),
1067 size: None,
1068 });
1069
1070 let stage = DmgStage;
1071 stage.run(&mut ctx).unwrap();
1072
1073 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
1074 assert_eq!(dmgs.len(), 1);
1075
1076 let dmg_path = dmgs[0].path.to_string_lossy();
1077 assert!(
1078 dmg_path.ends_with("myapp-2.0.0-darwin-arm64.dmg"),
1079 "expected template-rendered name, got: {dmg_path}"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_stage_dry_run_replace_removes_archives() {
1085 use anodizer_core::config::{Config, CrateConfig, DmgConfig};
1086 use anodizer_core::context::{Context, ContextOptions};
1087
1088 let tmp = tempfile::TempDir::new().unwrap();
1089
1090 let dmg_cfg = DmgConfig {
1091 replace: Some(true),
1092 ..Default::default()
1093 };
1094
1095 let crate_cfg = CrateConfig {
1096 name: "myapp".to_string(),
1097 path: ".".to_string(),
1098 tag_template: "v{{ .Version }}".to_string(),
1099 dmgs: Some(vec![dmg_cfg]),
1100 ..Default::default()
1101 };
1102
1103 let mut config = Config::default();
1104 config.project_name = "myapp".to_string();
1105 config.dist = tmp.path().join("dist");
1106 config.crates = vec![crate_cfg];
1107
1108 let mut ctx = Context::new(
1109 config,
1110 ContextOptions {
1111 dry_run: true,
1112 ..Default::default()
1113 },
1114 );
1115 ctx.template_vars_mut().set("Version", "1.0.0");
1116
1117 ctx.artifacts.add(Artifact {
1119 kind: ArtifactKind::Binary,
1120 name: String::new(),
1121 path: PathBuf::from("dist/myapp"),
1122 target: Some("aarch64-apple-darwin".to_string()),
1123 crate_name: "myapp".to_string(),
1124 metadata: Default::default(),
1125 size: None,
1126 });
1127
1128 ctx.artifacts.add(Artifact {
1130 kind: ArtifactKind::Archive,
1131 name: String::new(),
1132 path: PathBuf::from("dist/myapp_1.0.0_darwin_arm64.tar.gz"),
1133 target: Some("aarch64-apple-darwin".to_string()),
1134 crate_name: "myapp".to_string(),
1135 metadata: HashMap::from([("format".to_string(), "tar.gz".to_string())]),
1136 size: None,
1137 });
1138
1139 ctx.artifacts.add(Artifact {
1141 kind: ArtifactKind::Archive,
1142 name: String::new(),
1143 path: PathBuf::from("dist/myapp_1.0.0_linux_amd64.tar.gz"),
1144 target: Some("x86_64-unknown-linux-gnu".to_string()),
1145 crate_name: "myapp".to_string(),
1146 metadata: HashMap::from([("format".to_string(), "tar.gz".to_string())]),
1147 size: None,
1148 });
1149
1150 let stage = DmgStage;
1151 stage.run(&mut ctx).unwrap();
1152
1153 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
1155 assert_eq!(dmgs.len(), 1);
1156
1157 let archives = ctx.artifacts.by_kind(ArtifactKind::Archive);
1159 assert_eq!(archives.len(), 1, "only the Linux archive should remain");
1160 assert!(
1161 archives[0].target.as_deref().unwrap().contains("linux"),
1162 "remaining archive should be the Linux one"
1163 );
1164 }
1165
1166 #[test]
1167 fn test_config_parse_dmg() {
1168 let yaml = r#"
1169project_name: test
1170crates:
1171 - name: test
1172 path: "."
1173 tag_template: "v{{ .Version }}"
1174 dmgs:
1175 - name: "{{ ProjectName }}_{{ Version }}_{{ Arch }}.dmg"
1176"#;
1177 let config: anodizer_core::config::Config = serde_yaml_ng::from_str(yaml).unwrap();
1178 let dmgs = config.crates[0].dmgs.as_ref().unwrap();
1179 assert_eq!(dmgs.len(), 1);
1180 assert_eq!(
1181 dmgs[0].name.as_deref(),
1182 Some("{{ ProjectName }}_{{ Version }}_{{ Arch }}.dmg")
1183 );
1184 assert!(dmgs[0].skip.is_none());
1185 assert!(dmgs[0].replace.is_none());
1186 }
1187
1188 #[test]
1189 fn test_config_parse_dmg_full() {
1190 let yaml = r#"
1191project_name: test
1192crates:
1193 - name: test
1194 path: "."
1195 tag_template: "v{{ .Version }}"
1196 dmgs:
1197 - id: macos-dmg
1198 ids:
1199 - build_darwin_arm64
1200 - build_darwin_amd64
1201 name: "myapp-{{ Version }}-{{ Os }}-{{ Arch }}.dmg"
1202 extra_files:
1203 - README.md
1204 - LICENSE
1205 replace: true
1206 mod_timestamp: "{{ .CommitTimestamp }}"
1207 skip: false
1208"#;
1209 let config: anodizer_core::config::Config = serde_yaml_ng::from_str(yaml).unwrap();
1210 let dmgs = config.crates[0].dmgs.as_ref().unwrap();
1211 assert_eq!(dmgs.len(), 1);
1212
1213 let dmg = &dmgs[0];
1214 assert_eq!(dmg.id.as_deref(), Some("macos-dmg"));
1215 assert_eq!(
1216 dmg.ids.as_ref().unwrap(),
1217 &vec![
1218 "build_darwin_arm64".to_string(),
1219 "build_darwin_amd64".to_string()
1220 ]
1221 );
1222 assert_eq!(
1223 dmg.name.as_deref(),
1224 Some("myapp-{{ Version }}-{{ Os }}-{{ Arch }}.dmg")
1225 );
1226 let extras = dmg.extra_files.as_ref().unwrap();
1227 assert_eq!(extras.len(), 2);
1228 assert_eq!(extras[0].glob(), "README.md");
1229 assert_eq!(extras[1].glob(), "LICENSE");
1230 assert_eq!(dmg.replace, Some(true));
1231 assert_eq!(dmg.mod_timestamp.as_deref(), Some("{{ .CommitTimestamp }}"));
1232 assert_eq!(
1233 dmg.skip,
1234 Some(anodizer_core::config::StringOrBool::Bool(false))
1235 );
1236 }
1237
1238 #[test]
1239 fn test_invalid_name_template_errors() {
1240 use anodizer_core::config::{Config, CrateConfig, DmgConfig};
1241 use anodizer_core::context::{Context, ContextOptions};
1242
1243 let tmp = tempfile::TempDir::new().unwrap();
1244
1245 let dmg_cfg = DmgConfig {
1246 name: Some("{{ ProjectName }}_{{ Version".to_string()),
1248 ..Default::default()
1249 };
1250
1251 let crate_cfg = CrateConfig {
1252 name: "myapp".to_string(),
1253 path: ".".to_string(),
1254 tag_template: "v{{ .Version }}".to_string(),
1255 dmgs: Some(vec![dmg_cfg]),
1256 ..Default::default()
1257 };
1258
1259 let mut config = Config::default();
1260 config.project_name = "myapp".to_string();
1261 config.dist = tmp.path().join("dist");
1262 config.crates = vec![crate_cfg];
1263
1264 let mut ctx = Context::new(
1265 config,
1266 ContextOptions {
1267 dry_run: true,
1268 ..Default::default()
1269 },
1270 );
1271 ctx.template_vars_mut().set("Version", "1.0.0");
1272
1273 ctx.artifacts.add(Artifact {
1275 kind: ArtifactKind::Binary,
1276 name: String::new(),
1277 path: PathBuf::from("dist/myapp"),
1278 target: Some("aarch64-apple-darwin".to_string()),
1279 crate_name: "myapp".to_string(),
1280 metadata: Default::default(),
1281 size: None,
1282 });
1283
1284 let stage = DmgStage;
1285 let result = stage.run(&mut ctx);
1286 assert!(
1287 result.is_err(),
1288 "expected error from invalid template, got Ok"
1289 );
1290 let err_msg = format!("{:#}", result.unwrap_err());
1291 assert!(
1292 err_msg.contains("render") || err_msg.contains("template") || err_msg.contains("dmg"),
1293 "error should mention template rendering, got: {err_msg}"
1294 );
1295 }
1296
1297 #[test]
1298 fn test_extra_files_copied_to_staging() {
1299 use anodizer_core::config::{Config, CrateConfig, DmgConfig};
1300 use anodizer_core::context::{Context, ContextOptions};
1301
1302 let tmp = tempfile::TempDir::new().unwrap();
1303
1304 let binary_path = tmp.path().join("myapp");
1306 fs::write(&binary_path, b"fake-binary").unwrap();
1307
1308 let extra_path = tmp.path().join("README.md");
1309 fs::write(&extra_path, b"readme content").unwrap();
1310
1311 let dmg_cfg = DmgConfig {
1312 extra_files: Some(vec![anodizer_core::config::ExtraFileSpec::Glob(
1313 extra_path.to_string_lossy().into_owned(),
1314 )]),
1315 ..Default::default()
1316 };
1317
1318 let crate_cfg = CrateConfig {
1319 name: "myapp".to_string(),
1320 path: ".".to_string(),
1321 tag_template: "v{{ .Version }}".to_string(),
1322 dmgs: Some(vec![dmg_cfg]),
1323 ..Default::default()
1324 };
1325
1326 let mut config = Config::default();
1327 config.project_name = "myapp".to_string();
1328 config.dist = tmp.path().join("dist");
1329 config.crates = vec![crate_cfg];
1330
1331 let mut ctx = Context::new(
1333 config,
1334 ContextOptions {
1335 dry_run: false,
1336 ..Default::default()
1337 },
1338 );
1339 ctx.template_vars_mut().set("Version", "1.0.0");
1340
1341 ctx.artifacts.add(Artifact {
1342 kind: ArtifactKind::Binary,
1343 name: String::new(),
1344 path: binary_path,
1345 target: Some("aarch64-apple-darwin".to_string()),
1346 crate_name: "myapp".to_string(),
1347 metadata: Default::default(),
1348 size: None,
1349 });
1350
1351 let stage = DmgStage;
1352 let result = stage.run(&mut ctx);
1353
1354 if dmg_tool().is_some() {
1359 assert!(
1360 result.is_ok(),
1361 "stage should succeed when a DMG tool is available, got: {:#}",
1362 result.unwrap_err()
1363 );
1364 assert!(
1365 ctx.artifacts
1366 .all()
1367 .iter()
1368 .any(|a| a.kind == ArtifactKind::DiskImage),
1369 "a DiskImage artifact should be registered when imaging succeeds"
1370 );
1371 } else {
1372 assert!(result.is_err(), "expected failure due to missing DMG tool");
1373 let err_msg = format!("{:#}", result.unwrap_err());
1374 assert!(
1375 err_msg.contains("hdiutil")
1376 || err_msg.contains("genisoimage")
1377 || err_msg.contains("mkisofs")
1378 || err_msg.contains("DMG creation tool")
1379 || err_msg.contains("no DMG"),
1380 "error should mention missing DMG tool (staging succeeded), got: {err_msg}"
1381 );
1382 }
1383 }
1384
1385 #[test]
1386 fn test_stage_dry_run_multiple_configs() {
1387 use anodizer_core::config::{Config, CrateConfig, DmgConfig};
1388 use anodizer_core::context::{Context, ContextOptions};
1389
1390 let tmp = tempfile::TempDir::new().unwrap();
1391
1392 let dmg_cfg_1 = DmgConfig {
1394 id: Some("installer".to_string()),
1395 name: Some("{{ ProjectName }}-installer-{{ Arch }}.dmg".to_string()),
1396 ..Default::default()
1397 };
1398 let dmg_cfg_2 = DmgConfig {
1399 id: Some("portable".to_string()),
1400 name: Some("{{ ProjectName }}-portable-{{ Arch }}.dmg".to_string()),
1401 ..Default::default()
1402 };
1403
1404 let crate_cfg = CrateConfig {
1405 name: "myapp".to_string(),
1406 path: ".".to_string(),
1407 tag_template: "v{{ .Version }}".to_string(),
1408 dmgs: Some(vec![dmg_cfg_1, dmg_cfg_2]),
1409 ..Default::default()
1410 };
1411
1412 let mut config = Config::default();
1413 config.project_name = "myapp".to_string();
1414 config.dist = tmp.path().join("dist");
1415 config.crates = vec![crate_cfg];
1416
1417 let mut ctx = Context::new(
1418 config,
1419 ContextOptions {
1420 dry_run: true,
1421 ..Default::default()
1422 },
1423 );
1424 ctx.template_vars_mut().set("Version", "1.0.0");
1425
1426 ctx.artifacts.add(Artifact {
1428 kind: ArtifactKind::Binary,
1429 name: String::new(),
1430 path: PathBuf::from("dist/myapp"),
1431 target: Some("aarch64-apple-darwin".to_string()),
1432 crate_name: "myapp".to_string(),
1433 metadata: Default::default(),
1434 size: None,
1435 });
1436
1437 let stage = DmgStage;
1438 stage.run(&mut ctx).unwrap();
1439
1440 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
1442 assert_eq!(dmgs.len(), 2, "should produce one DMG per config entry");
1443
1444 let names: Vec<String> = dmgs
1446 .iter()
1447 .map(|a| a.path.file_name().unwrap().to_string_lossy().into_owned())
1448 .collect();
1449 assert!(
1450 names.iter().any(|n| n.contains("installer")),
1451 "expected an 'installer' DMG, got: {names:?}"
1452 );
1453 assert!(
1454 names.iter().any(|n| n.contains("portable")),
1455 "expected a 'portable' DMG, got: {names:?}"
1456 );
1457
1458 let ids: Vec<Option<&String>> = dmgs.iter().map(|a| a.metadata.get("id")).collect();
1459 assert!(
1460 ids.contains(&Some(&"installer".to_string())),
1461 "expected id=installer in metadata"
1462 );
1463 assert!(
1464 ids.contains(&Some(&"portable".to_string())),
1465 "expected id=portable in metadata"
1466 );
1467 }
1468
1469 #[test]
1470 fn test_ids_filtering() {
1471 use anodizer_core::config::{Config, CrateConfig, DmgConfig};
1472 use anodizer_core::context::{Context, ContextOptions};
1473
1474 let tmp = tempfile::TempDir::new().unwrap();
1475
1476 let dmg_cfg = DmgConfig {
1478 ids: Some(vec!["build-darwin-arm64".to_string()]),
1479 ..Default::default()
1480 };
1481
1482 let crate_cfg = CrateConfig {
1483 name: "myapp".to_string(),
1484 path: ".".to_string(),
1485 tag_template: "v{{ .Version }}".to_string(),
1486 dmgs: Some(vec![dmg_cfg]),
1487 ..Default::default()
1488 };
1489
1490 let mut config = Config::default();
1491 config.project_name = "myapp".to_string();
1492 config.dist = tmp.path().join("dist");
1493 config.crates = vec![crate_cfg];
1494
1495 let mut ctx = Context::new(
1496 config,
1497 ContextOptions {
1498 dry_run: true,
1499 ..Default::default()
1500 },
1501 );
1502 ctx.template_vars_mut().set("Version", "1.0.0");
1503
1504 ctx.artifacts.add(Artifact {
1506 kind: ArtifactKind::Binary,
1507 name: String::new(),
1508 path: PathBuf::from("dist/myapp-arm64"),
1509 target: Some("aarch64-apple-darwin".to_string()),
1510 crate_name: "myapp".to_string(),
1511 metadata: HashMap::from([("id".to_string(), "build-darwin-arm64".to_string())]),
1512 size: None,
1513 });
1514 ctx.artifacts.add(Artifact {
1515 kind: ArtifactKind::Binary,
1516 name: String::new(),
1517 path: PathBuf::from("dist/myapp-amd64"),
1518 target: Some("x86_64-apple-darwin".to_string()),
1519 crate_name: "myapp".to_string(),
1520 metadata: HashMap::from([("id".to_string(), "build-darwin-amd64".to_string())]),
1521 size: None,
1522 });
1523
1524 let stage = DmgStage;
1525 stage.run(&mut ctx).unwrap();
1526
1527 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
1529 assert_eq!(
1530 dmgs.len(),
1531 1,
1532 "ids filter should produce exactly one DMG, got {}",
1533 dmgs.len()
1534 );
1535 assert_eq!(
1536 dmgs[0].target.as_deref(),
1537 Some("aarch64-apple-darwin"),
1538 "the DMG should be for the arm64 target"
1539 );
1540 }
1541
1542 #[test]
1543 fn test_use_appbundle_selects_installer_artifacts() {
1544 use anodizer_core::config::{Config, CrateConfig, DmgConfig};
1545 use anodizer_core::context::{Context, ContextOptions};
1546
1547 let tmp = tempfile::TempDir::new().unwrap();
1548
1549 let dmg_cfg = DmgConfig {
1550 use_: Some("appbundle".to_string()),
1551 ..Default::default()
1552 };
1553
1554 let crate_cfg = CrateConfig {
1555 name: "myapp".to_string(),
1556 path: ".".to_string(),
1557 tag_template: "v{{ .Version }}".to_string(),
1558 dmgs: Some(vec![dmg_cfg]),
1559 ..Default::default()
1560 };
1561
1562 let mut config = Config::default();
1563 config.project_name = "myapp".to_string();
1564 config.dist = tmp.path().join("dist");
1565 config.crates = vec![crate_cfg];
1566
1567 let mut ctx = Context::new(
1568 config,
1569 ContextOptions {
1570 dry_run: true,
1571 ..Default::default()
1572 },
1573 );
1574 ctx.template_vars_mut().set("Version", "1.0.0");
1575
1576 ctx.artifacts.add(Artifact {
1578 kind: ArtifactKind::Installer,
1579 name: String::new(),
1580 path: PathBuf::from("dist/MyApp.app"),
1581 target: Some("aarch64-apple-darwin".to_string()),
1582 crate_name: "myapp".to_string(),
1583 metadata: HashMap::from([("format".to_string(), "appbundle".to_string())]),
1584 size: None,
1585 });
1586
1587 ctx.artifacts.add(Artifact {
1589 kind: ArtifactKind::Binary,
1590 name: String::new(),
1591 path: PathBuf::from("dist/myapp"),
1592 target: Some("aarch64-apple-darwin".to_string()),
1593 crate_name: "myapp".to_string(),
1594 metadata: Default::default(),
1595 size: None,
1596 });
1597
1598 let stage = DmgStage;
1599 stage.run(&mut ctx).unwrap();
1600
1601 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
1603 assert_eq!(dmgs.len(), 1, "should produce one DMG from the appbundle");
1604 assert_eq!(dmgs[0].metadata.get("format").unwrap(), "dmg");
1605 }
1606
1607 #[test]
1608 fn test_use_binary_selects_darwin_binaries() {
1609 use anodizer_core::config::{Config, CrateConfig, DmgConfig};
1610 use anodizer_core::context::{Context, ContextOptions};
1611
1612 let tmp = tempfile::TempDir::new().unwrap();
1613
1614 let dmg_cfg = DmgConfig {
1616 use_: Some("binary".to_string()),
1617 ..Default::default()
1618 };
1619
1620 let crate_cfg = CrateConfig {
1621 name: "myapp".to_string(),
1622 path: ".".to_string(),
1623 tag_template: "v{{ .Version }}".to_string(),
1624 dmgs: Some(vec![dmg_cfg]),
1625 ..Default::default()
1626 };
1627
1628 let mut config = Config::default();
1629 config.project_name = "myapp".to_string();
1630 config.dist = tmp.path().join("dist");
1631 config.crates = vec![crate_cfg];
1632
1633 let mut ctx = Context::new(
1634 config,
1635 ContextOptions {
1636 dry_run: true,
1637 ..Default::default()
1638 },
1639 );
1640 ctx.template_vars_mut().set("Version", "1.0.0");
1641
1642 ctx.artifacts.add(Artifact {
1644 kind: ArtifactKind::Binary,
1645 name: String::new(),
1646 path: PathBuf::from("dist/myapp"),
1647 target: Some("aarch64-apple-darwin".to_string()),
1648 crate_name: "myapp".to_string(),
1649 metadata: Default::default(),
1650 size: None,
1651 });
1652
1653 ctx.artifacts.add(Artifact {
1655 kind: ArtifactKind::Installer,
1656 name: String::new(),
1657 path: PathBuf::from("dist/MyApp.app"),
1658 target: Some("aarch64-apple-darwin".to_string()),
1659 crate_name: "myapp".to_string(),
1660 metadata: HashMap::from([("format".to_string(), "appbundle".to_string())]),
1661 size: None,
1662 });
1663
1664 let stage = DmgStage;
1665 stage.run(&mut ctx).unwrap();
1666
1667 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
1669 assert_eq!(dmgs.len(), 1, "should produce one DMG from the binary");
1670 assert_eq!(dmgs[0].metadata.get("format").unwrap(), "dmg");
1671 }
1672
1673 #[test]
1674 fn test_use_default_selects_darwin_binaries() {
1675 use anodizer_core::config::{Config, CrateConfig, DmgConfig};
1676 use anodizer_core::context::{Context, ContextOptions};
1677
1678 let tmp = tempfile::TempDir::new().unwrap();
1679
1680 let dmg_cfg = DmgConfig::default();
1682
1683 let crate_cfg = CrateConfig {
1684 name: "myapp".to_string(),
1685 path: ".".to_string(),
1686 tag_template: "v{{ .Version }}".to_string(),
1687 dmgs: Some(vec![dmg_cfg]),
1688 ..Default::default()
1689 };
1690
1691 let mut config = Config::default();
1692 config.project_name = "myapp".to_string();
1693 config.dist = tmp.path().join("dist");
1694 config.crates = vec![crate_cfg];
1695
1696 let mut ctx = Context::new(
1697 config,
1698 ContextOptions {
1699 dry_run: true,
1700 ..Default::default()
1701 },
1702 );
1703 ctx.template_vars_mut().set("Version", "1.0.0");
1704
1705 ctx.artifacts.add(Artifact {
1706 kind: ArtifactKind::Binary,
1707 name: String::new(),
1708 path: PathBuf::from("dist/myapp"),
1709 target: Some("aarch64-apple-darwin".to_string()),
1710 crate_name: "myapp".to_string(),
1711 metadata: Default::default(),
1712 size: None,
1713 });
1714
1715 let stage = DmgStage;
1716 stage.run(&mut ctx).unwrap();
1717
1718 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
1719 assert_eq!(
1720 dmgs.len(),
1721 1,
1722 "default (omitted) use should select darwin binaries"
1723 );
1724 }
1725
1726 #[test]
1727 fn test_invalid_use_value_errors() {
1728 use anodizer_core::config::{Config, CrateConfig, DmgConfig};
1729 use anodizer_core::context::{Context, ContextOptions};
1730
1731 let tmp = tempfile::TempDir::new().unwrap();
1732
1733 let dmg_cfg = DmgConfig {
1734 use_: Some("invalid_mode".to_string()),
1735 ..Default::default()
1736 };
1737
1738 let crate_cfg = CrateConfig {
1739 name: "myapp".to_string(),
1740 path: ".".to_string(),
1741 tag_template: "v{{ .Version }}".to_string(),
1742 dmgs: Some(vec![dmg_cfg]),
1743 ..Default::default()
1744 };
1745
1746 let mut config = Config::default();
1747 config.project_name = "myapp".to_string();
1748 config.dist = tmp.path().join("dist");
1749 config.crates = vec![crate_cfg];
1750
1751 let mut ctx = Context::new(
1752 config,
1753 ContextOptions {
1754 dry_run: true,
1755 ..Default::default()
1756 },
1757 );
1758 ctx.template_vars_mut().set("Version", "1.0.0");
1759
1760 ctx.artifacts.add(Artifact {
1762 kind: ArtifactKind::Binary,
1763 name: String::new(),
1764 path: PathBuf::from("dist/myapp"),
1765 target: Some("aarch64-apple-darwin".to_string()),
1766 crate_name: "myapp".to_string(),
1767 metadata: Default::default(),
1768 size: None,
1769 });
1770
1771 let stage = DmgStage;
1772 let result = stage.run(&mut ctx);
1773 assert!(result.is_err(), "expected error for invalid use value");
1774 let err_msg = format!("{:#}", result.unwrap_err());
1775 assert!(
1776 err_msg.contains("invalid_mode") && err_msg.contains("binary"),
1777 "error should mention the invalid value and expected options, got: {err_msg}"
1778 );
1779 }
1780
1781 #[test]
1782 fn test_disable_string_or_bool_true() {
1783 use anodizer_core::config::{Config, CrateConfig, DmgConfig, StringOrBool};
1784 use anodizer_core::context::{Context, ContextOptions};
1785
1786 let dmg_cfg = DmgConfig {
1788 skip: Some(StringOrBool::String("true".to_string())),
1789 ..Default::default()
1790 };
1791
1792 let crate_cfg = CrateConfig {
1793 name: "myapp".to_string(),
1794 path: ".".to_string(),
1795 tag_template: "v{{ .Version }}".to_string(),
1796 dmgs: Some(vec![dmg_cfg]),
1797 ..Default::default()
1798 };
1799
1800 let mut config = Config::default();
1801 config.project_name = "myapp".to_string();
1802 config.crates = vec![crate_cfg];
1803
1804 let mut ctx = Context::new(
1805 config,
1806 ContextOptions {
1807 dry_run: true,
1808 ..Default::default()
1809 },
1810 );
1811 ctx.template_vars_mut().set("Version", "1.0.0");
1812
1813 ctx.artifacts.add(Artifact {
1814 kind: ArtifactKind::Binary,
1815 name: String::new(),
1816 path: PathBuf::from("dist/myapp"),
1817 target: Some("aarch64-apple-darwin".to_string()),
1818 crate_name: "myapp".to_string(),
1819 metadata: Default::default(),
1820 size: None,
1821 });
1822
1823 let stage = DmgStage;
1824 stage.run(&mut ctx).unwrap();
1825
1826 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
1827 assert!(dmgs.is_empty(), "string 'true' should disable the config");
1828 }
1829
1830 #[test]
1831 fn test_disable_string_or_bool_false() {
1832 use anodizer_core::config::{Config, CrateConfig, DmgConfig, StringOrBool};
1833 use anodizer_core::context::{Context, ContextOptions};
1834
1835 let tmp = tempfile::TempDir::new().unwrap();
1836
1837 let dmg_cfg = DmgConfig {
1839 skip: Some(StringOrBool::String("false".to_string())),
1840 ..Default::default()
1841 };
1842
1843 let crate_cfg = CrateConfig {
1844 name: "myapp".to_string(),
1845 path: ".".to_string(),
1846 tag_template: "v{{ .Version }}".to_string(),
1847 dmgs: Some(vec![dmg_cfg]),
1848 ..Default::default()
1849 };
1850
1851 let mut config = Config::default();
1852 config.project_name = "myapp".to_string();
1853 config.dist = tmp.path().join("dist");
1854 config.crates = vec![crate_cfg];
1855
1856 let mut ctx = Context::new(
1857 config,
1858 ContextOptions {
1859 dry_run: true,
1860 ..Default::default()
1861 },
1862 );
1863 ctx.template_vars_mut().set("Version", "1.0.0");
1864
1865 ctx.artifacts.add(Artifact {
1866 kind: ArtifactKind::Binary,
1867 name: String::new(),
1868 path: PathBuf::from("dist/myapp"),
1869 target: Some("aarch64-apple-darwin".to_string()),
1870 crate_name: "myapp".to_string(),
1871 metadata: Default::default(),
1872 size: None,
1873 });
1874
1875 let stage = DmgStage;
1876 stage.run(&mut ctx).unwrap();
1877
1878 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
1879 assert_eq!(
1880 dmgs.len(),
1881 1,
1882 "string 'false' should not disable the config"
1883 );
1884 }
1885
1886 #[test]
1887 fn test_disable_template_string() {
1888 use anodizer_core::config::{Config, CrateConfig, DmgConfig, StringOrBool};
1889 use anodizer_core::context::{Context, ContextOptions};
1890
1891 let dmg_cfg = DmgConfig {
1893 skip: Some(StringOrBool::String(
1894 "{% if IsSnapshot %}true{% endif %}".to_string(),
1895 )),
1896 ..Default::default()
1897 };
1898
1899 let crate_cfg = CrateConfig {
1900 name: "myapp".to_string(),
1901 path: ".".to_string(),
1902 tag_template: "v{{ .Version }}".to_string(),
1903 dmgs: Some(vec![dmg_cfg]),
1904 ..Default::default()
1905 };
1906
1907 let mut config = Config::default();
1908 config.project_name = "myapp".to_string();
1909 config.crates = vec![crate_cfg];
1910
1911 let mut ctx = Context::new(
1912 config,
1913 ContextOptions {
1914 dry_run: true,
1915 ..Default::default()
1916 },
1917 );
1918 ctx.template_vars_mut().set("Version", "1.0.0");
1919 ctx.template_vars_mut().set("IsSnapshot", "true");
1920
1921 ctx.artifacts.add(Artifact {
1922 kind: ArtifactKind::Binary,
1923 name: String::new(),
1924 path: PathBuf::from("dist/myapp"),
1925 target: Some("aarch64-apple-darwin".to_string()),
1926 crate_name: "myapp".to_string(),
1927 metadata: Default::default(),
1928 size: None,
1929 });
1930
1931 let stage = DmgStage;
1932 stage.run(&mut ctx).unwrap();
1933
1934 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
1935 assert!(
1936 dmgs.is_empty(),
1937 "template should evaluate to true and disable the config"
1938 );
1939 }
1940
1941 #[test]
1942 fn test_config_parse_dmg_with_use() {
1943 let yaml = r#"
1944project_name: test
1945crates:
1946 - name: test
1947 path: "."
1948 tag_template: "v{{ .Version }}"
1949 dmgs:
1950 - name: "{{ ProjectName }}_{{ Version }}_{{ Arch }}.dmg"
1951 use: appbundle
1952"#;
1953 let config: anodizer_core::config::Config = serde_yaml_ng::from_str(yaml).unwrap();
1954 let dmgs = config.crates[0].dmgs.as_ref().unwrap();
1955 assert_eq!(dmgs.len(), 1);
1956 assert_eq!(dmgs[0].use_.as_deref(), Some("appbundle"));
1957 }
1958
1959 #[test]
1960 fn test_config_parse_dmg_disable_string() {
1961 let yaml = r#"
1962project_name: test
1963crates:
1964 - name: test
1965 path: "."
1966 tag_template: "v{{ .Version }}"
1967 dmgs:
1968 - skip: "{% if IsSnapshot %}true{% endif %}"
1969"#;
1970 let config: anodizer_core::config::Config = serde_yaml_ng::from_str(yaml).unwrap();
1971 let dmgs = config.crates[0].dmgs.as_ref().unwrap();
1972 assert_eq!(
1973 dmgs[0].skip,
1974 Some(anodizer_core::config::StringOrBool::String(
1975 "{% if IsSnapshot %}true{% endif %}".to_string()
1976 ))
1977 );
1978 }
1979
1980 #[test]
1981 fn test_use_appbundle_skips_when_no_appbundles() {
1982 use anodizer_core::config::{Config, CrateConfig, DmgConfig};
1983 use anodizer_core::context::{Context, ContextOptions};
1984
1985 let tmp = tempfile::TempDir::new().unwrap();
1986
1987 let dmg_cfg = DmgConfig {
1988 use_: Some("appbundle".to_string()),
1989 ..Default::default()
1990 };
1991
1992 let crate_cfg = CrateConfig {
1993 name: "myapp".to_string(),
1994 path: ".".to_string(),
1995 tag_template: "v{{ .Version }}".to_string(),
1996 dmgs: Some(vec![dmg_cfg]),
1997 ..Default::default()
1998 };
1999
2000 let mut config = Config::default();
2001 config.project_name = "myapp".to_string();
2002 config.dist = tmp.path().join("dist");
2003 config.crates = vec![crate_cfg];
2004
2005 let mut ctx = Context::new(
2006 config,
2007 ContextOptions {
2008 dry_run: true,
2009 ..Default::default()
2010 },
2011 );
2012 ctx.template_vars_mut().set("Version", "1.0.0");
2013
2014 ctx.artifacts.add(Artifact {
2016 kind: ArtifactKind::Binary,
2017 name: String::new(),
2018 path: PathBuf::from("dist/myapp"),
2019 target: Some("aarch64-apple-darwin".to_string()),
2020 crate_name: "myapp".to_string(),
2021 metadata: Default::default(),
2022 size: None,
2023 });
2024
2025 let stage = DmgStage;
2026 stage.run(&mut ctx).unwrap();
2027
2028 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
2030 assert!(
2031 dmgs.is_empty(),
2032 "should produce no DMGs when use=appbundle but no appbundles exist"
2033 );
2034 }
2035
2036 fn dmg_if_test_ctx(if_expr: Option<&str>) -> anodizer_core::context::Context {
2039 use anodizer_core::config::{Config, CrateConfig, DmgConfig};
2040 use anodizer_core::context::{Context, ContextOptions};
2041 let tmp = tempfile::TempDir::new().unwrap();
2042 let mut config = Config::default();
2043 config.project_name = "myapp".to_string();
2044 config.dist = tmp.path().join("dist");
2045 std::fs::create_dir_all(&config.dist).unwrap();
2046 let dmg_cfg = DmgConfig {
2047 if_condition: if_expr.map(str::to_string),
2048 ..Default::default()
2049 };
2050 config.crates = vec![CrateConfig {
2051 name: "myapp".to_string(),
2052 path: ".".to_string(),
2053 tag_template: "v{{ .Version }}".to_string(),
2054 dmgs: Some(vec![dmg_cfg]),
2055 ..Default::default()
2056 }];
2057 let mut ctx = Context::new(
2058 config,
2059 ContextOptions {
2060 dry_run: true,
2061 ..Default::default()
2062 },
2063 );
2064 ctx.template_vars_mut().set("Version", "1.0.0");
2065 ctx.template_vars_mut().set("Os", "darwin");
2066 ctx.artifacts.add(Artifact {
2068 kind: ArtifactKind::Binary,
2069 name: String::new(),
2070 path: PathBuf::from("dist/myapp"),
2071 target: Some("aarch64-apple-darwin".to_string()),
2072 crate_name: "myapp".to_string(),
2073 metadata: Default::default(),
2074 size: None,
2075 });
2076 ctx
2077 }
2078
2079 #[test]
2080 fn test_dmg_if_false_skips_config() {
2081 let mut ctx = dmg_if_test_ctx(Some("false"));
2082 DmgStage.run(&mut ctx).unwrap();
2083 assert_eq!(
2084 ctx.artifacts.by_kind(ArtifactKind::DiskImage).len(),
2085 0,
2086 "dmg if=false should skip, producing no DiskImage artifacts"
2087 );
2088 }
2089
2090 #[test]
2091 fn test_dmg_if_empty_string_skips_config() {
2092 let mut ctx = dmg_if_test_ctx(Some("{{ if false }}{{ end }}"));
2093 DmgStage.run(&mut ctx).unwrap();
2094 assert_eq!(ctx.artifacts.by_kind(ArtifactKind::DiskImage).len(), 0);
2095 }
2096
2097 #[test]
2098 fn test_dmg_if_render_failure_is_hard_error() {
2099 let mut ctx = dmg_if_test_ctx(Some("{{ undefined_function 42 }}"));
2100 let err = DmgStage
2101 .run(&mut ctx)
2102 .expect_err("unrenderable `if` should hard-error");
2103 let msg = format!("{:#}", err);
2104 assert!(
2105 msg.contains("`if` template render failed"),
2106 "error should name the `if` render failure, got: {msg}"
2107 );
2108 }
2109
2110 fn dmg_amd64_variant_test_ctx(amd64_variant: Option<&str>) -> anodizer_core::context::Context {
2119 use anodizer_core::config::{Config, CrateConfig, DmgConfig};
2120 use anodizer_core::context::{Context, ContextOptions};
2121 let tmp = tempfile::TempDir::new().unwrap();
2122 let mut config = Config::default();
2123 config.project_name = "myapp".to_string();
2124 config.dist = tmp.path().join("dist");
2125 std::fs::create_dir_all(&config.dist).unwrap();
2126 let dmg_cfg = DmgConfig {
2127 amd64_variant: amd64_variant.map(str::to_string),
2128 ..Default::default()
2129 };
2130 config.crates = vec![CrateConfig {
2131 name: "myapp".to_string(),
2132 path: ".".to_string(),
2133 tag_template: "v{{ .Version }}".to_string(),
2134 dmgs: Some(vec![dmg_cfg]),
2135 ..Default::default()
2136 }];
2137 let mut ctx = Context::new(
2138 config,
2139 ContextOptions {
2140 dry_run: true,
2141 ..Default::default()
2142 },
2143 );
2144 ctx.template_vars_mut().set("Version", "1.0.0");
2145
2146 for variant in ["v1", "v2", "v3"] {
2148 ctx.artifacts.add(Artifact {
2149 kind: ArtifactKind::Binary,
2150 name: String::new(),
2151 path: PathBuf::from(format!("dist/myapp_{variant}")),
2152 target: Some("x86_64-apple-darwin".to_string()),
2153 crate_name: "myapp".to_string(),
2154 metadata: HashMap::from([("amd64_variant".to_string(), variant.to_string())]),
2155 size: None,
2156 });
2157 }
2158 ctx.artifacts.add(Artifact {
2159 kind: ArtifactKind::Binary,
2160 name: String::new(),
2161 path: PathBuf::from("dist/myapp_arm"),
2162 target: Some("aarch64-apple-darwin".to_string()),
2163 crate_name: "myapp".to_string(),
2164 metadata: Default::default(),
2165 size: None,
2166 });
2167 ctx
2168 }
2169
2170 #[test]
2171 fn test_dmg_amd64_variant_unset_passes_all_amd64_variants() {
2172 let mut ctx = dmg_amd64_variant_test_ctx(None);
2173 DmgStage.run(&mut ctx).unwrap();
2174 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
2177 assert_eq!(
2178 dmgs.len(),
2179 2,
2180 "unset amd64_variant should pass every variant; one DMG per target"
2181 );
2182 }
2183
2184 #[test]
2185 fn test_dmg_amd64_variant_v3_only_keeps_matching_variant() {
2186 let mut ctx = dmg_amd64_variant_test_ctx(Some("v3"));
2187 DmgStage.run(&mut ctx).unwrap();
2188 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
2189 assert_eq!(dmgs.len(), 2);
2192 let targets: Vec<&str> = dmgs.iter().map(|a| a.target.as_deref().unwrap()).collect();
2193 assert!(targets.contains(&"x86_64-apple-darwin"));
2194 assert!(targets.contains(&"aarch64-apple-darwin"));
2195 }
2196
2197 #[test]
2198 fn test_dmg_amd64_variant_filter_does_not_drop_arm64() {
2199 let mut ctx = dmg_amd64_variant_test_ctx(Some("v9000")); DmgStage.run(&mut ctx).unwrap();
2203 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
2204 assert_eq!(dmgs.len(), 1);
2206 assert_eq!(dmgs[0].target.as_deref(), Some("aarch64-apple-darwin"));
2207 }
2208
2209 #[test]
2214 fn test_default_name_template_matches_gr_shape() {
2215 use anodizer_core::config::{Config, CrateConfig, DmgConfig};
2216 use anodizer_core::context::{Context, ContextOptions};
2217
2218 let tmp = tempfile::TempDir::new().unwrap();
2219 let mut config = Config::default();
2220 config.project_name = "myapp".to_string();
2221 config.dist = tmp.path().join("dist");
2222 config.crates = vec![CrateConfig {
2223 name: "myapp".to_string(),
2224 path: ".".to_string(),
2225 tag_template: "v{{ .Version }}".to_string(),
2226 dmgs: Some(vec![DmgConfig::default()]),
2227 ..Default::default()
2228 }];
2229
2230 let mut ctx = Context::new(
2231 config,
2232 ContextOptions {
2233 dry_run: true,
2234 ..Default::default()
2235 },
2236 );
2237 ctx.template_vars_mut().set("Version", "1.0.0");
2238 ctx.artifacts.add(Artifact {
2239 kind: ArtifactKind::Binary,
2240 name: String::new(),
2241 path: PathBuf::from("dist/myapp"),
2242 target: Some("aarch64-apple-darwin".to_string()),
2243 crate_name: "myapp".to_string(),
2244 metadata: Default::default(),
2245 size: None,
2246 });
2247
2248 DmgStage.run(&mut ctx).unwrap();
2249
2250 let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
2251 assert_eq!(dmgs.len(), 1);
2252 let name = dmgs[0].path.file_name().unwrap().to_string_lossy();
2253 assert!(
2255 name.starts_with("myapp_") && name.ends_with("arm64.dmg"),
2256 "default name should be ProjectName_Arch.dmg, got: {name}"
2257 );
2258 assert!(
2259 !name.contains("1.0.0"),
2260 "default name must not embed the version, got: {name}"
2261 );
2262 }
2263
2264 #[test]
2269 fn test_resolve_mod_timestamp_renders_built_in_var() {
2270 use anodizer_core::config::Config;
2271 use anodizer_core::context::{Context, ContextOptions};
2272
2273 let mut config = Config::default();
2274 config.project_name = "myapp".to_string();
2275 let mut ctx = Context::new(config, ContextOptions::default());
2276 ctx.template_vars_mut().set("Version", "1.2.3");
2277
2278 let rendered = resolve_mod_timestamp(&ctx, "{{ Version }}").unwrap();
2279 assert_eq!(rendered, "1.2.3");
2280 }
2281
2282 #[test]
2283 fn test_resolve_mod_timestamp_surfaces_render_errors() {
2284 use anodizer_core::config::Config;
2285 use anodizer_core::context::{Context, ContextOptions};
2286
2287 let ctx = Context::new(Config::default(), ContextOptions::default());
2288 let err = resolve_mod_timestamp(&ctx, "{{ Version").expect_err("malformed template");
2290 let msg = format!("{err:#}");
2291 assert!(
2292 msg.contains("mod_timestamp"),
2293 "error must name mod_timestamp, got: {msg}"
2294 );
2295 }
2296
2297 #[test]
2302 fn test_resolve_volume_name_renders_template() {
2303 use anodizer_core::config::{Config, DmgConfig};
2304 use anodizer_core::context::{Context, ContextOptions};
2305
2306 let mut config = Config::default();
2307 config.project_name = "myapp".to_string();
2308 let mut ctx = Context::new(config, ContextOptions::default());
2309 ctx.template_vars_mut().set("ProjectName", "myapp");
2310
2311 let dmg_cfg = DmgConfig {
2312 volume_name: Some("{{ ProjectName }}-Installer".to_string()),
2313 ..Default::default()
2314 };
2315
2316 let resolved = resolve_volume_name(&ctx, &dmg_cfg, "myapp").unwrap();
2317 assert_eq!(resolved, "myapp-Installer");
2318 }
2319
2320 #[test]
2321 fn test_resolve_volume_name_falls_back_to_project_name() {
2322 use anodizer_core::config::{Config, DmgConfig};
2323 use anodizer_core::context::{Context, ContextOptions};
2324
2325 let ctx = Context::new(Config::default(), ContextOptions::default());
2326 let dmg_cfg = DmgConfig {
2327 volume_name: None,
2328 ..Default::default()
2329 };
2330
2331 let resolved = resolve_volume_name(&ctx, &dmg_cfg, "fallback-project").unwrap();
2332 assert_eq!(resolved, "fallback-project");
2333 }
2334
2335 #[test]
2336 fn test_resolve_volume_name_surfaces_render_errors() {
2337 use anodizer_core::config::{Config, DmgConfig};
2338 use anodizer_core::context::{Context, ContextOptions};
2339
2340 let ctx = Context::new(Config::default(), ContextOptions::default());
2341 let dmg_cfg = DmgConfig {
2342 volume_name: Some("{{ ProjectName".to_string()),
2343 ..Default::default()
2344 };
2345 let err = resolve_volume_name(&ctx, &dmg_cfg, "myapp").expect_err("malformed template");
2346 let msg = format!("{err:#}");
2347 assert!(
2348 msg.contains("volume_name"),
2349 "error must name volume_name, got: {msg}"
2350 );
2351 }
2352
2353 #[test]
2358 fn test_extra_files_multi_match_name_template_bails() {
2359 use anodizer_core::config::{Config, CrateConfig, DmgConfig, ExtraFileSpec};
2360 use anodizer_core::context::{Context, ContextOptions};
2361
2362 let tmp = tempfile::TempDir::new().unwrap();
2363 fs::write(tmp.path().join("a.txt"), b"a").unwrap();
2365 fs::write(tmp.path().join("b.txt"), b"b").unwrap();
2366
2367 let glob_pattern = format!("{}/*.txt", tmp.path().display());
2368 let spec = ExtraFileSpec::Detailed {
2369 glob: glob_pattern,
2370 name_template: Some("output.txt".to_string()),
2371 allow_empty: false,
2372 };
2373
2374 let dmg_cfg = DmgConfig {
2375 extra_files: Some(vec![spec]),
2376 ..Default::default()
2377 };
2378
2379 let mut config = Config::default();
2380 config.project_name = "myapp".to_string();
2381 config.dist = tmp.path().join("dist");
2382 config.crates = vec![CrateConfig {
2383 name: "myapp".to_string(),
2384 path: ".".to_string(),
2385 tag_template: "v{{ .Version }}".to_string(),
2386 dmgs: Some(vec![dmg_cfg]),
2387 ..Default::default()
2388 }];
2389
2390 let mut ctx = Context::new(
2391 config,
2392 ContextOptions {
2393 dry_run: true,
2394 ..Default::default()
2395 },
2396 );
2397 ctx.template_vars_mut().set("Version", "1.0.0");
2398 ctx.artifacts.add(Artifact {
2399 kind: ArtifactKind::Binary,
2400 name: String::new(),
2401 path: PathBuf::from("dist/myapp"),
2402 target: Some("aarch64-apple-darwin".to_string()),
2403 crate_name: "myapp".to_string(),
2404 metadata: Default::default(),
2405 size: None,
2406 });
2407
2408 let err = DmgStage
2409 .run(&mut ctx)
2410 .expect_err("multi-match glob + name_template must bail");
2411 let msg = format!("{err:#}");
2412 assert!(
2413 msg.contains("name_template") && msg.contains("exactly one"),
2414 "error should mention name_template and single-match requirement, got: {msg}"
2415 );
2416 }
2417
2418 #[test]
2423 fn test_duplicate_staged_filename_bails() {
2424 use anodizer_core::config::{Config, CrateConfig, DmgConfig};
2425 use anodizer_core::context::{Context, ContextOptions};
2426
2427 let tmp = tempfile::TempDir::new().unwrap();
2428 let dir_a = tmp.path().join("a");
2430 let dir_b = tmp.path().join("b");
2431 fs::create_dir_all(&dir_a).unwrap();
2432 fs::create_dir_all(&dir_b).unwrap();
2433 fs::write(dir_a.join("myapp"), b"binary-a").unwrap();
2434 fs::write(dir_b.join("myapp"), b"binary-b").unwrap();
2435
2436 let mut config = Config::default();
2437 config.project_name = "myapp".to_string();
2438 config.dist = tmp.path().join("dist");
2439 config.crates = vec![CrateConfig {
2440 name: "myapp".to_string(),
2441 path: ".".to_string(),
2442 tag_template: "v{{ .Version }}".to_string(),
2443 dmgs: Some(vec![DmgConfig::default()]),
2444 ..Default::default()
2445 }];
2446
2447 let mut ctx = Context::new(config, ContextOptions::default());
2449 ctx.template_vars_mut().set("Version", "1.0.0");
2450
2451 for dir in [&dir_a, &dir_b] {
2453 ctx.artifacts.add(Artifact {
2454 kind: ArtifactKind::Binary,
2455 name: String::new(),
2456 path: dir.join("myapp"),
2457 target: Some("aarch64-apple-darwin".to_string()),
2458 crate_name: "myapp".to_string(),
2459 metadata: Default::default(),
2460 size: None,
2461 });
2462 }
2463
2464 let err = DmgStage
2465 .run(&mut ctx)
2466 .expect_err("duplicate filename must bail");
2467 let msg = format!("{err:#}");
2468 assert!(
2469 msg.contains("duplicate") && msg.contains("myapp"),
2470 "error should mention duplicate and the conflicting filename, got: {msg}"
2471 );
2472 }
2473
2474 #[cfg(unix)]
2479 #[test]
2480 fn test_applications_symlink_created_for_appbundle() {
2481 let tmp = tempfile::tempdir().unwrap();
2482 maybe_create_applications_symlink(tmp.path(), "appbundle").unwrap();
2483
2484 let link = tmp.path().join("Applications");
2485 assert!(
2486 link.symlink_metadata().is_ok(),
2487 "symlink entry not created at {}",
2488 link.display()
2489 );
2490 let target = std::fs::read_link(&link).unwrap();
2491 assert_eq!(target, std::path::Path::new("/Applications"));
2492 }
2493
2494 #[cfg(unix)]
2495 #[test]
2496 fn test_applications_symlink_skipped_for_binary() {
2497 let tmp = tempfile::tempdir().unwrap();
2498 maybe_create_applications_symlink(tmp.path(), "binary").unwrap();
2499
2500 let link = tmp.path().join("Applications");
2501 assert!(
2502 link.symlink_metadata().is_err(),
2503 "no symlink should exist for use=binary, got entry at {}",
2504 link.display()
2505 );
2506 }
2507
2508 #[cfg(unix)]
2509 #[test]
2510 fn test_applications_symlink_idempotent() {
2511 let tmp = tempfile::tempdir().unwrap();
2513 maybe_create_applications_symlink(tmp.path(), "appbundle").unwrap();
2514 maybe_create_applications_symlink(tmp.path(), "appbundle").unwrap();
2515 let link = tmp.path().join("Applications");
2516 assert_eq!(
2517 std::fs::read_link(&link).unwrap(),
2518 std::path::Path::new("/Applications")
2519 );
2520 }
2521
2522 #[cfg(unix)]
2523 #[test]
2524 fn test_stage_binary_into_chmods_binary_use_mode_to_executable() {
2525 use std::os::unix::fs::PermissionsExt;
2526
2527 let tmp = tempfile::tempdir().unwrap();
2528 let src = tmp.path().join("payload");
2529 std::fs::write(&src, b"not really a binary").unwrap();
2530 std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o644)).unwrap();
2533
2534 let staging = tmp.path().join("staging");
2535 std::fs::create_dir_all(&staging).unwrap();
2536
2537 let staged = stage_binary_into(&staging, &src, "payload", "binary").unwrap();
2538 let mode = std::fs::metadata(&staged).unwrap().permissions().mode() & 0o777;
2539 assert_eq!(
2540 mode, 0o755,
2541 "binary use_mode must produce a 0o755 file, got 0o{mode:o}"
2542 );
2543 assert!(
2544 mode & 0o111 != 0,
2545 "binary in DMG must be executable, got 0o{mode:o}"
2546 );
2547 }
2548
2549 #[cfg(unix)]
2550 #[test]
2551 fn test_stage_binary_into_copies_app_bundle_directory_tree() {
2552 use std::os::unix::fs::PermissionsExt;
2553
2554 let tmp = tempfile::tempdir().unwrap();
2555
2556 let app = tmp.path().join("anodizer.app");
2558 let macos = app.join("Contents/MacOS");
2559 std::fs::create_dir_all(&macos).unwrap();
2560 let plist = app.join("Contents/Info.plist");
2561 std::fs::write(&plist, b"<plist></plist>").unwrap();
2562 let inner_bin = macos.join("anodizer");
2563 std::fs::write(&inner_bin, b"\x7fELF fake mach-o").unwrap();
2564 std::fs::set_permissions(&inner_bin, std::fs::Permissions::from_mode(0o755)).unwrap();
2565
2566 let staging = tmp.path().join("staging");
2567 std::fs::create_dir_all(&staging).unwrap();
2568
2569 let staged = stage_binary_into(&staging, &app, "anodizer.app", "appbundle").unwrap();
2570 assert_eq!(staged, staging.join("anodizer.app"));
2571 assert!(staged.is_dir(), "staged .app must be a directory");
2572
2573 let staged_bin = staged.join("Contents/MacOS/anodizer");
2575 assert!(staged_bin.exists(), "inner binary must be staged");
2576 assert_eq!(
2577 std::fs::read(&staged_bin).unwrap(),
2578 std::fs::read(&inner_bin).unwrap(),
2579 "inner binary bytes must match source"
2580 );
2581
2582 let mode = std::fs::metadata(&staged_bin).unwrap().permissions().mode();
2584 assert!(
2585 mode & 0o100 != 0,
2586 "inner binary must retain user-exec bit, got 0o{:o}",
2587 mode & 0o777
2588 );
2589
2590 assert!(
2592 staged.join("Contents/Info.plist").exists(),
2593 "Info.plist must be staged"
2594 );
2595 }
2596
2597 #[cfg(unix)]
2598 #[test]
2599 fn test_stage_binary_into_recreates_symlinks_in_app_bundle() {
2600 let tmp = tempfile::tempdir().unwrap();
2601
2602 let app = tmp.path().join("anodizer.app");
2603 let versions = app.join("Contents/Frameworks/Foo.framework/Versions");
2604 std::fs::create_dir_all(versions.join("A")).unwrap();
2605 std::fs::write(versions.join("A/Foo"), b"framework binary").unwrap();
2606 std::os::unix::fs::symlink("A", versions.join("Current")).unwrap();
2608
2609 let staging = tmp.path().join("staging");
2610 std::fs::create_dir_all(&staging).unwrap();
2611
2612 let staged = stage_binary_into(&staging, &app, "anodizer.app", "appbundle").unwrap();
2613 let staged_link = staged.join("Contents/Frameworks/Foo.framework/Versions/Current");
2614 let meta = std::fs::symlink_metadata(&staged_link).unwrap();
2615 assert!(
2616 meta.file_type().is_symlink(),
2617 "embedded framework symlink must be recreated as a symlink"
2618 );
2619 assert_eq!(
2620 std::fs::read_link(&staged_link).unwrap(),
2621 std::path::Path::new("A"),
2622 "symlink target must be preserved"
2623 );
2624 }
2625}