Skip to main content

anodizer_stage_dmg/
lib.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::PathBuf;
4use std::process::Command;
5
6use anyhow::{Context as _, Result};
7
8use anodizer_core::artifact::{Artifact, ArtifactKind};
9use anodizer_core::context::Context;
10use anodizer_core::stage::Stage;
11
12// ---------------------------------------------------------------------------
13// DmgTool detection
14// ---------------------------------------------------------------------------
15
16/// Which CLI tool to use for creating DMG/ISO images.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum DmgTool {
19    /// macOS native — `hdiutil create`
20    Hdiutil,
21    /// Linux fallback — `genisoimage`
22    Genisoimage,
23    /// Linux second fallback — `mkisofs`
24    Mkisofs,
25}
26
27/// Detect which DMG creation tool is available on the system.
28///
29/// Preference order: hdiutil (macOS native) > genisoimage > mkisofs.
30/// Returns `None` if no suitable tool is found.
31pub 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
43// ---------------------------------------------------------------------------
44// dmg_command
45// ---------------------------------------------------------------------------
46
47/// Construct CLI arguments for creating a DMG/ISO from a staging directory.
48///
49/// - `tool`: which CLI to invoke
50/// - `vol_name`: the volume label
51/// - `staging_dir`: path to the directory whose contents go into the image
52/// - `output_path`: path to the output `.dmg` file
53pub 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
99// ---------------------------------------------------------------------------
100// DmgStage
101// ---------------------------------------------------------------------------
102
103pub struct DmgStage;
104
105/// Parse Os and Arch from a Rust target triple using the shared mapping.
106fn os_arch_from_target(target: Option<&str>) -> (String, String) {
107    anodizer_core::target::os_arch_with_default(target, "darwin")
108}
109
110/// Default output filename template `{{ ProjectName }}_{{ Arch }}` (the `.dmg`
111/// extension is appended automatically). In workspace per-crate mode the
112/// `ProjectName` var is rebound to each crate's name so the rendered filename
113/// is distinct per crate.
114const DEFAULT_NAME_TEMPLATE: &str = "{{ ProjectName }}_{{ Arch }}";
115
116/// Stage the DMG payload for one source into `staging_dir`, dispatching on
117/// `use_mode`, and return the staged path (`staging_dir.join(binary_name)`).
118///
119/// - `use_mode == "binary"`: `binary_path` is a regular file; it is copied and
120///   then forced to mode `0o755` on Unix. `fs::copy` preserves source
121///   permissions, so a binary unpacked from an archive that stripped the
122///   execute bit would otherwise ship inside the DMG as non-executable.
123/// - `use_mode == "appbundle"`: `binary_path` is a `.app` bundle directory; it
124///   is copied recursively (preserving the tree, file contents, Unix mode bits,
125///   and embedded symlinks). The top level is a directory, so no chmod is
126///   applied — the bundle already carries the executable bit on its inner
127///   `Contents/MacOS/<binary>`.
128pub(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/// Insert an `/Applications` symlink into the staging directory when
157/// packaging an app bundle, giving mounted DMGs the standard drag-and-drop
158/// install UX. No-op for any other `use_mode`.
159///
160/// On Windows hosts the symlink may not resolve correctly when the image
161/// is mounted; this helper is `#[cfg(unix)]` so non-Unix builds skip it
162/// silently.
163#[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
183/// Resolve the volume label for a DMG: render the configured `volume_name`
184/// template, or fall back to the project name when unset.
185pub(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
198/// Render a `mod_timestamp` template through Tera, returning the resolved
199/// string ready to feed to `apply_mod_timestamp` / `parse_mod_timestamp`.
200pub(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        // Collect crates that have dmg config
217        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        // In workspace per-crate mode the same pipeline run produces a DMG for
233        // each crate. Rebinding the `ProjectName` template var to the current
234        // crate's name (mirroring the archive stage) keeps default name
235        // templates like `{{ ProjectName }}_{{ Arch }}` distinct per crate so
236        // two crates' DMGs don't render the same filename and clobber each
237        // other. The original value is restored after the loop.
238        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        // Capture the loop result rather than `?`-ing inside it: a per-crate
249        // failure must still restore the rebound `ProjectName` below before
250        // propagating, so the workspace value never leaks past this stage.
251        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                // Per-crate volume-label default: the crate's name in multi-crate
260                // mode so the mounted volume disambiguates like the filename does;
261                // the workspace project name otherwise.
262                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                    // `dmg.if`: template-conditional skip (opt-in).
271                    // Render error => hard bail (avoids the W1 silent-skip
272                    // footgun: user's typo must surface, not silently ship a
273                    // release without the DMG they asked for).
274                    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                    // Skip configs marked skip:
288                    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                    // Validate `use` field
301                    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                    // Pre-flight: resolve extra_files through the canonical
311                    // resolver so a constant name_template paired with a
312                    // multi-match glob (which would silently overwrite every
313                    // match to the same dst) fails before any subprocess spawn
314                    // and in dry-run too. The resolved set is recomputed at copy
315                    // time below.
316                    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                    // Collect source artifacts depending on `use` mode
322                    let source_artifacts: Vec<Artifact> = if use_mode == "appbundle" {
323                        // Collect Installer artifacts with format=appbundle for this crate
324                        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                        // Collect darwin Binary artifacts for this crate
337                        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                    // Filter by build IDs if specified
351                    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                    // `amd64_variant` filter.
368                    // amd64-variant filtering:
369                    // only constrains `amd64` artifacts. Non-amd64 always passes.
370                    // Unset `amd64_variant` metadata is treated as `v1`.
371                    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                    // Warn and skip if no source artifacts found
387                    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                    // Group binaries by target so a multi-binary crate (e.g. CLI
413                    // with several `bin = ` entries) produces ONE DMG per target
414                    // containing all binaries — the per-target
415                    // DMG layout, not per-binary.
416                    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                        // Derive Os/Arch from the target triple for template rendering
427                        let (os, arch) = os_arch_from_target(target.as_deref());
428
429                        // Set Os/Arch/Target in template vars for this iteration
430                        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                        // Determine output filename from name template or default
436                        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                        // Ensure the filename ends with .dmg (case-insensitive)
448                        let dmg_filename = if dmg_filename.to_lowercase().ends_with(".dmg") {
449                            dmg_filename
450                        } else {
451                            format!("{dmg_filename}.dmg")
452                        };
453
454                        // Output goes in dist/macos/
455                        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                        // Resolve each source binary's staged leaf name ONCE: the
461                        // pre-flight duplicate check and the copy loop below both
462                        // need it, so compute the (path, leaf-name) pairs here and
463                        // reuse them rather than re-deriving `file_name()` twice.
464                        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                        // Pre-flight: two source paths with the same leaf name would
477                        // silently overwrite the first during staging. Detect early so
478                        // this fires in both dry-run and live mode.
479                        {
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                            // If replace is set, mark archives for this crate+target for removal
520                            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                        // Live mode — detect tool
531                        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                        // Create output directory
538                        fs::create_dir_all(&output_dir).with_context(|| {
539                            format!("create dmg output dir: {}", output_dir.display())
540                        })?;
541
542                        // Create staging directory
543                        let staging_tmp =
544                            tempfile::tempdir().context("create temp dir for dmg staging")?;
545                        let staging_dir = staging_tmp.path();
546
547                        // Copy every binary for this target into the staging dir,
548                        // reusing the leaf names resolved for the pre-flight above.
549                        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                        // Copy extra files into staging dir via the canonical
557                        // resolver (dedup + sort + bail-on-multi-match when a
558                        // name_template is set).
559                        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                        // Process templated_extra_files: render and copy to staging dir
580                        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                        // On macOS, detach a stale mount at the same volume path before
597                        // creating a new image. Silent best-effort — a non-zero exit
598                        // (e.g. nothing mounted) is not an error.
599                        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                        // Build and run the command
612                        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                        // If replace is set, mark archives for this crate+target for removal
658                        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        // Remove replaced archives
679        if !archives_to_remove.is_empty() {
680            ctx.artifacts.remove_by_paths(&archives_to_remove);
681        }
682
683        // Register new DMG artifacts
684        for artifact in new_artifacts {
685            ctx.artifacts.add(artifact);
686        }
687
688        Ok(())
689    }
690}
691
692// ---------------------------------------------------------------------------
693// Tests
694// ---------------------------------------------------------------------------
695
696/// Environment requirements for the dmg stage: one of the image tools the
697/// stage's detection ladder accepts (`hdiutil` > `genisoimage` > `mkisofs`)
698/// when any active `dmgs:` entry exists and the configured build targets
699/// include macOS (the stage only images darwin binaries).
700pub 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        // dmg_tool() returns an Option<DmgTool>. On CI/Linux it may or may not
740        // find genisoimage/mkisofs. We just verify the return type is correct.
741        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        // DmgStage should be a no-op when crates have no dmg block
821        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        // Add a darwin binary so the stage has something to potentially process
860        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        // No DMG artifacts should be produced because config is disabled
874        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        // Register darwin binary artifacts
910        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        // Two darwin binaries -> two DMG artifacts
933        let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
934        assert_eq!(dmgs.len(), 2);
935
936        // All should have format=dmg metadata
937        for dmg in &dmgs {
938            assert_eq!(dmg.metadata.get("format").unwrap(), "dmg");
939            assert_eq!(dmg.kind, ArtifactKind::DiskImage);
940        }
941
942        // Check targets are preserved
943        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        // Two crates in one workspace, both using the DEFAULT name template
956        // (no Version segment, so ProjectName is the only distinguishing token).
957        // Before the per-crate ProjectName rebind, both render to
958        // `<project_name>_arm64.dmg` and clobber each other.
959        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        // The ProjectName var must be restored to the workspace value after the
1018        // stage so downstream stages don't inherit the last crate's name.
1019        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        // Register a darwin binary
1118        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        // Register an archive artifact for the same crate+target
1129        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        // Also register a Linux archive that should NOT be removed
1140        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        // DMG artifact should be registered
1154        let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
1155        assert_eq!(dmgs.len(), 1);
1156
1157        // The darwin archive should have been removed (replace: true)
1158        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            // Tera will error on unclosed tags
1247            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        // Add a darwin binary so we actually attempt to render the template
1274        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        // Create a fake binary and extra file on disk
1305        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        // Run in LIVE mode (not dry_run) so staging dir logic is exercised
1332        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        // Outcome depends on whether a DMG-imaging tool is installed on this
1355        // host (hdiutil/genisoimage/mkisofs), not on the OS: with a tool present
1356        // the stage images the staging dir and succeeds; with none it errors
1357        // after staging with a tool-missing message.
1358        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        // Two separate DMG configs for the same crate, with different names
1393        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        // One darwin binary
1427        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        // Two configs x one binary = two DMG artifacts
1441        let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
1442        assert_eq!(dmgs.len(), 2, "should produce one DMG per config entry");
1443
1444        // Verify both have distinct filenames and IDs
1445        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        // Configure ids filter to match only one build id
1477        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        // Register two darwin binaries with different metadata ids
1505        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        // Verify only one DMG artifact is produced (the arm64 one)
1528        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        // Register an appbundle artifact (Installer with format=appbundle)
1577        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        // Also register a darwin binary that should NOT be selected
1588        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        // Should produce one DMG from the appbundle, not from the binary
1602        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        // Explicit `use: binary` should behave same as omitted (default)
1615        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        // Register a darwin binary
1643        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        // Also register an appbundle that should NOT be selected
1654        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        // Should produce one DMG from the binary, not from the appbundle
1668        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        // Omitted `use` field should default to binary mode
1681        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        // Add a darwin binary so the stage actually runs
1761        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        // Test with StringOrBool::String("true")
1787        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        // Test with StringOrBool::String("false") — should NOT be disabled
1838        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        // Template that evaluates to "true" when IsSnapshot is truthy
1892        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        // Only register a binary — no appbundles
2015        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        // No DMGs should be produced because there are no appbundle artifacts
2029        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    // --- `dmg.if` template-conditional ---
2037
2038    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        // Seed a binary so DmgStage has something to package.
2067        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    // -------------------------------------------------------------------
2111    // `dmg.amd64_variant` filter
2112    // -------------------------------------------------------------------
2113
2114    /// Build a context with three darwin/amd64 binaries (variants v1/v2/v3)
2115    /// and one darwin/arm64 binary. The `amd64_variant` field on the config
2116    /// drives which subset of amd64 binaries makes it into DiskImage
2117    /// artifacts; arm64 is always included regardless.
2118    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        // Three amd64 variants (v1/v2/v3) + one arm64 (no variant tag).
2147        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        // 3 amd64 variants share one target -> grouped into ONE DMG;
2175        // 1 arm64 -> one DMG. Total: 2 DMGs.
2176        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        // Only the v3 amd64 binary survives (one amd64 DMG) + the arm64
2190        // binary (one arm64 DMG). v1 and v2 are filtered out.
2191        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        // Pin: filter only constrains amd64 — arm64 must still pass even
2200        // when the filter rejects every amd64 variant present.
2201        let mut ctx = dmg_amd64_variant_test_ctx(Some("v9000")); // matches no variant
2202        DmgStage.run(&mut ctx).unwrap();
2203        let dmgs = ctx.artifacts.by_kind(ArtifactKind::DiskImage);
2204        // No amd64 survives; arm64 still produces one DMG.
2205        assert_eq!(dmgs.len(), 1);
2206        assert_eq!(dmgs[0].target.as_deref(), Some("aarch64-apple-darwin"));
2207    }
2208
2209    // -------------------------------------------------------------------
2210    // Default name template shape
2211    // -------------------------------------------------------------------
2212
2213    #[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        // Default: ProjectName_Arch (no version segment, .dmg appended)
2254        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    // -------------------------------------------------------------------
2265    // mod_timestamp template rendering — positive assertion via helper
2266    // -------------------------------------------------------------------
2267
2268    #[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        // Unclosed tag — Tera should reject it.
2289        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    // -------------------------------------------------------------------
2298    // volume_name resolution — positive assertion via helper
2299    // -------------------------------------------------------------------
2300
2301    #[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    // -------------------------------------------------------------------
2354    // extra_files multi-match + constant name_template must bail
2355    // -------------------------------------------------------------------
2356
2357    #[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        // Create two files that a glob will match.
2364        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    // -------------------------------------------------------------------
2419    // Duplicate filename in staging bails with a clear error
2420    // -------------------------------------------------------------------
2421
2422    #[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        // Two real binary files with the same leaf name in different dirs.
2429        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        // Live mode so the staging copy runs.
2448        let mut ctx = Context::new(config, ContextOptions::default());
2449        ctx.template_vars_mut().set("Version", "1.0.0");
2450
2451        // Two binaries with the same filename under the same target.
2452        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    // -------------------------------------------------------------------
2475    // /Applications symlink helper — positive + negative assertion
2476    // -------------------------------------------------------------------
2477
2478    #[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        // Two invocations on the same staging dir must not fail.
2512        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        // Strip the executable bit on the source to simulate an artifact unpacked
2531        // from a tarball that did not preserve perms.
2532        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        // Build a fake `.app` bundle directory tree.
2557        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        // The inner binary must be copied with identical bytes.
2574        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        // The executable bit must survive the recursive copy.
2583        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        // The plist must be present too.
2591        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        // Embedded framework version symlink: Current -> A.
2607        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}