Skip to main content

anodizer_core/config/publishers/
homebrew.rs

1use std::collections::HashMap;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use super::super::{StringOrBool, deserialize_string_or_bool_opt};
7use super::{CommitAuthorConfig, RepositoryConfig};
8
9// ---------------------------------------------------------------------------
10// HomebrewConfig / ScoopConfig / TapConfig / BucketConfig
11// ---------------------------------------------------------------------------
12
13#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
14#[serde(default, deny_unknown_fields)]
15pub struct HomebrewConfig {
16    /// Unified repository config with branch, token, PR, git SSH support.
17    /// (Replaces the legacy `tap: TapConfig` owner/name-only form.)
18    pub repository: Option<RepositoryConfig>,
19    /// Commit author with optional signing.
20    pub commit_author: Option<CommitAuthorConfig>,
21    /// Formula directory in the tap (e.g. "Formula").
22    pub directory: Option<String>,
23    /// Override the formula name (default: crate name).
24    pub name: Option<String>,
25    /// Short description of the formula (shown in `brew info`).
26    pub description: Option<String>,
27    /// SPDX license identifier (e.g., "MIT", "Apache-2.0").
28    pub license: Option<String>,
29    /// Ruby `install` block content for the formula.
30    pub install: Option<String>,
31    /// Additional install commands appended after the main install block.
32    pub extra_install: Option<String>,
33    /// Post-install commands (separate `def post_install` block in formula).
34    pub post_install: Option<String>,
35    /// Ruby `test` block content for the formula (run by `brew test`).
36    pub test: Option<String>,
37    /// Project homepage URL. Falls back to the GitHub release URL when unset.
38    pub homepage: Option<String>,
39    /// Package dependencies (e.g. `openssl`, `libgit2`).
40    pub dependencies: Option<Vec<HomebrewDependency>>,
41    /// Conflicting formula names with optional reason.
42    pub conflicts: Option<Vec<HomebrewConflict>>,
43    /// Post-install user-facing notes shown by `brew info`.
44    pub caveats: Option<String>,
45    /// Skip publishing the formula.  `"true"` always skips; `"auto"` skips
46    /// for prerelease versions. Accepts bool or template string.
47    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
48    pub skip_upload: Option<StringOrBool>,
49    /// Custom commit message template. Rendered via Tera with the standard
50    /// release template variables (`ProjectName`, `Tag`, `Version`, etc.).
51    /// Default: `"Brew formula update for {{ ProjectName }} version {{ Tag }}"`
52    /// (set in `crates/stage-publish/src/homebrew.rs::default_commit_msg_template`).
53    pub commit_msg_template: Option<String>,
54    // Legacy flat `commit_author_name` / `commit_author_email` fields are
55    // gone; use the structured `commit_author: { name, email, signing }`.
56    /// Build IDs filter: only include artifacts whose `id` is in this list.
57    pub ids: Option<Vec<String>>,
58    /// Custom URL template for download URLs (overrides release URL).
59    pub url_template: Option<String>,
60    /// HTTP headers to include in download requests (e.g. for private repos).
61    pub url_headers: Option<Vec<String>>,
62    /// Custom download strategy class name (e.g. `:using => GitHubPrivateRepositoryReleaseDownloadStrategy`).
63    pub download_strategy: Option<String>,
64    /// Ruby `require` statement for custom download strategies.
65    pub custom_require: Option<String>,
66    /// Custom Ruby code block inserted into the formula class body.
67    pub custom_block: Option<String>,
68    /// Launchd plist content for `brew services`.
69    pub plist: Option<String>,
70    /// Homebrew service block content (alternative to plist).
71    pub service: Option<String>,
72    /// Homebrew Cask configuration (macOS .app bundles).
73    pub cask: Option<HomebrewCaskConfig>,
74    /// amd64 microarchitecture variant filter (e.g. "v1", "v2", "v3", "v4").
75    /// Only artifacts matching this variant are included. Default: "v1".
76    pub amd64_variant: Option<String>,
77    /// ARM version filter (e.g. "6", "7"). Only artifacts matching this
78    /// variant are included.
79    pub arm_variant: Option<String>,
80    /// Override whether this publisher failing should fail the overall release.
81    ///
82    /// Default: `false` — a failure here is logged but does not abort the release.
83    /// Set to `true` to fail the release on any error.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub required: Option<bool>,
86    /// Template-conditional gate: when the rendered result is falsy
87    /// (`"false"` / `"0"` / `"no"` / empty), the Homebrew publisher is
88    /// skipped. Render failure hard-errors. Config key: `brews[].if:`.
89    #[serde(rename = "if")]
90    pub if_condition: Option<String>,
91    /// When `true`, a triggered rollback leaves this publisher's work in
92    /// place rather than attempting to undo it. Default `false`.
93    pub retain_on_rollback: Option<bool>,
94}
95
96#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
97#[serde(default, deny_unknown_fields)]
98pub struct HomebrewDependency {
99    /// Homebrew formula name of the dependency.
100    pub name: String,
101    /// Restrict to a specific OS: `"mac"` or `"linux"`.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub os: Option<String>,
104    /// Dependency type, e.g. `"optional"`.
105    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
106    pub dep_type: Option<String>,
107    /// Version constraint for the dependency (e.g. `">= 1.1"`).
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub version: Option<String>,
110}
111
112/// A Homebrew conflict entry, supporting both a bare name string and a
113/// structured object with an optional `because` reason.
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
115#[serde(untagged)]
116pub enum HomebrewConflict {
117    /// Just the formula name (e.g. `"other-tool"`).
118    Name(String),
119    /// Name with reason (e.g. `{name: "other-tool", because: "both install a bin/foo binary"}`).
120    WithReason {
121        name: String,
122        #[serde(skip_serializing_if = "Option::is_none")]
123        because: Option<String>,
124    },
125}
126
127impl HomebrewConflict {
128    pub fn name(&self) -> &str {
129        match self {
130            Self::Name(n) => n,
131            Self::WithReason { name, .. } => name,
132        }
133    }
134    pub fn because(&self) -> Option<&str> {
135        match self {
136            Self::Name(_) => None,
137            Self::WithReason { because, .. } => because.as_deref(),
138        }
139    }
140}
141
142/// Unified Homebrew Cask configuration.
143///
144/// Used at both call-sites:
145/// - `homebrew_casks:` — top-level array; carries `repository`,
146///   `commit_author`, `directory`, `ids`, `url`, structured `uninstall`/`zap`, etc.
147/// - `crates[].publish.homebrew_cask:` — per-crate override; same shape, with
148///   `url_template` as the simpler URL alternative.
149///
150/// Fields from both original types are present; any field may be `None` at either
151/// call-site. The union avoids a two-type bifurcation while keeping both axes.
152#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
153#[serde(default, deny_unknown_fields)]
154pub struct HomebrewCaskConfig {
155    // ----- Identity -----
156    /// Cask name (default: crate / project name).
157    pub name: Option<String>,
158    /// Alternative cask names (aliases).
159    pub alternative_names: Option<Vec<String>>,
160
161    // ----- Tap repository (top-level axis) -----
162    /// Unified repository config for the Homebrew tap.
163    pub repository: Option<RepositoryConfig>,
164    /// Commit author with optional signing.
165    pub commit_author: Option<CommitAuthorConfig>,
166    /// Custom commit message template.
167    /// Default: "Brew cask update for {{ ProjectName }} version {{ Tag }}"
168    pub commit_msg_template: Option<String>,
169    /// Subdirectory in the tap repo for cask placement (default: "Casks").
170    pub directory: Option<String>,
171
172    // ----- Artifact selection -----
173    /// Build IDs filter: only include artifacts from builds whose `id` is in this list.
174    pub ids: Option<Vec<String>>,
175
176    // ----- Download URL -----
177    /// Simple URL template for the .dmg/.zip download (per-crate shorthand).
178    ///
179    /// Cannot be combined with `url.template:` — set one or the other.
180    /// If both are present, config validation rejects the config at parse time.
181    /// Use `url:` for the structured form (verified domain, custom headers, etc.)
182    /// or `url_template:` for a bare string shorthand — never both simultaneously.
183    pub url_template: Option<String>,
184    /// Structured download URL configuration (top-level axis).
185    pub url: Option<HomebrewCaskURL>,
186
187    // ----- macOS bundle -----
188    /// macOS .app bundle name (e.g. "MyApp.app").
189    pub app: Option<String>,
190    /// Binary stubs to create in /usr/local/bin.
191    ///
192    /// Each entry is either a bare string (`"my-cli"` → emits
193    /// `binary "my-cli"`) or a structured `{ name, target }` object
194    /// (`{ name: "my-cli", target: "mycli" }` → emits
195    /// `binary "my-cli", target: "mycli"`). The `target:` form mirrors
196    /// the Homebrew Ruby cask DSL for binary renames — without it, a
197    /// wrapped binary installs at the wrong path.
198    /// Cask binary entry.
199    pub binaries: Option<Vec<HomebrewCaskBinary>>,
200    /// Deprecated singular spelling of [`Self::binaries`]. The upstream
201    /// replaced `binary: foo` with `binaries: [foo]`; this field captures the
202    /// legacy spelling so imported configs keep parsing.
203    /// [`apply_homebrew_cask_legacy_singulars`](super::super::apply_homebrew_cask_legacy_singulars)
204    /// folds the value into [`Self::binaries`] at config-load time and emits
205    /// a one-time deprecation warning per occurrence. The field is excluded
206    /// from serialization so a round-tripped config emits only the canonical
207    /// plural form.
208    #[serde(default, rename = "binary", skip_serializing)]
209    pub legacy_binary: Option<String>,
210
211    // ----- Metadata -----
212    /// Cask description.
213    pub description: Option<String>,
214    /// Project homepage URL.
215    pub homepage: Option<String>,
216    /// License identifier (SPDX).
217    pub license: Option<String>,
218    /// Custom caveats shown after install.
219    pub caveats: Option<String>,
220
221    // ----- Ruby block -----
222    /// Arbitrary Ruby code inserted into the cask block.
223    pub custom_block: Option<String>,
224    /// Homebrew service definition.
225    pub service: Option<String>,
226
227    // ----- Completions / manpages -----
228    /// Manual page references to install.
229    pub manpages: Option<Vec<String>>,
230    /// Deprecated singular spelling of [`Self::manpages`]. The upstream replaced
231    /// `manpage: foo.1` with `manpages: [foo.1]`; this field captures the
232    /// legacy spelling so imported configs keep parsing.
233    /// [`apply_homebrew_cask_legacy_singulars`](super::super::apply_homebrew_cask_legacy_singulars)
234    /// folds the value into [`Self::manpages`] at config-load time and emits
235    /// a one-time deprecation warning per occurrence. The field is excluded
236    /// from serialization so a round-tripped config emits only the canonical
237    /// plural form.
238    #[serde(default, rename = "manpage", skip_serializing)]
239    pub legacy_manpage: Option<String>,
240    /// Shell completion definitions.
241    pub completions: Option<HomebrewCaskCompletions>,
242    /// Auto-generate shell completions from an executable.
243    pub generate_completions_from_executable: Option<HomebrewCaskGeneratedCompletions>,
244
245    // ----- Dependencies / conflicts -----
246    /// Cask dependencies (other casks or formulae).
247    pub dependencies: Option<Vec<HomebrewCaskDependencyEntry>>,
248    /// Conflicting casks or formulae.
249    pub conflicts: Option<Vec<HomebrewCaskConflictEntry>>,
250
251    // ----- Lifecycle hooks -----
252    /// Pre/post install/uninstall hooks.
253    pub hooks: Option<HomebrewCaskHooks>,
254
255    // ----- Uninstall / zap -----
256    /// Structured uninstall stanza configuration.
257    pub uninstall: Option<HomebrewCaskUninstall>,
258    /// Deep uninstall (zap) stanza configuration.
259    pub zap: Option<HomebrewCaskUninstall>,
260
261    // ----- Publishing control -----
262    /// Skip publishing the cask. `"true"` always skips; `"auto"` skips
263    /// for prerelease versions. Accepts bool or template string.
264    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
265    pub skip_upload: Option<StringOrBool>,
266    /// When true, force-push the updated cask file to the existing PR branch
267    /// when a PR for the same head branch already exists. The PR content is
268    /// updated in place rather than creating a duplicate. When false (default),
269    /// the push is skipped and a warning is emitted so the operator sees that
270    /// the publisher did not update the PR.
271    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
272    pub update_existing_pr: Option<StringOrBool>,
273    /// Override whether this publisher failing should fail the overall release.
274    ///
275    /// Default: `false` — a failure here is logged but does not abort the release.
276    /// Set to `true` to fail the release on any error.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub required: Option<bool>,
279    /// Template-conditional gate: when the rendered result is falsy
280    /// (`"false"` / `"0"` / `"no"` / empty), the Homebrew Cask config is
281    /// skipped. Render failure hard-errors. Config key: `homebrew_casks[].if:`.
282    #[serde(rename = "if")]
283    pub if_condition: Option<String>,
284    /// When `true`, a triggered rollback leaves this publisher's work in
285    /// place rather than attempting to undo it. Default `false`.
286    pub retain_on_rollback: Option<bool>,
287}
288
289/// Structured URL configuration for Homebrew Cask downloads.
290#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
291#[serde(default, deny_unknown_fields)]
292pub struct HomebrewCaskURL {
293    /// URL template for the download.
294    pub template: Option<String>,
295    /// Verification string (domain shown to user).
296    pub verified: Option<String>,
297    /// Custom downloader (e.g. `:homebrew_curl`, `:post`).
298    pub using: Option<String>,
299    /// HTTP cookies for the download.
300    pub cookies: Option<HashMap<String, String>>,
301    /// Referer header for the download.
302    pub referer: Option<String>,
303    /// Custom HTTP headers.
304    pub headers: Option<Vec<String>>,
305    /// Custom user agent string.
306    pub user_agent: Option<String>,
307    /// POST data for form submissions.
308    pub data: Option<HashMap<String, String>>,
309}
310
311/// Structured uninstall/zap configuration for Homebrew Cask.
312/// Used for both `uninstall` and `zap` stanzas.
313#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
314#[serde(default, deny_unknown_fields)]
315pub struct HomebrewCaskUninstall {
316    /// Launch daemon/agent identifiers to stop.
317    pub launchctl: Option<Vec<String>>,
318    /// Application bundle IDs to quit.
319    pub quit: Option<Vec<String>>,
320    /// Login item names to remove.
321    pub login_item: Option<Vec<String>>,
322    /// File paths to delete.
323    pub delete: Option<Vec<String>>,
324    /// File paths to trash (preserves app state).
325    pub trash: Option<Vec<String>>,
326}
327
328/// Pre/post install/uninstall hooks for Homebrew Cask.
329#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
330#[serde(default, deny_unknown_fields)]
331pub struct HomebrewCaskHooks {
332    /// Pre-install/uninstall hooks.
333    pub pre: Option<HomebrewCaskHook>,
334    /// Post-install/uninstall hooks.
335    pub post: Option<HomebrewCaskHook>,
336}
337
338/// Individual hook for install/uninstall phases.
339#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
340#[serde(default, deny_unknown_fields)]
341pub struct HomebrewCaskHook {
342    /// Ruby code for preflight/postflight during install.
343    pub install: Option<String>,
344    /// Ruby code for uninstall_preflight/uninstall_postflight.
345    pub uninstall: Option<String>,
346}
347
348/// Shell completion file paths for Homebrew Cask.
349#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
350#[serde(default, deny_unknown_fields)]
351pub struct HomebrewCaskCompletions {
352    /// Path to bash completion file.
353    pub bash: Option<String>,
354    /// Path to zsh completion file.
355    pub zsh: Option<String>,
356    /// Path to fish completion file.
357    pub fish: Option<String>,
358}
359
360/// Cask `binary` stanza entry.
361///
362/// Two shapes accepted in YAML:
363/// - bare string — `"my-cli"` → renders `binary "my-cli"`.
364/// - `{ name, target }` object — `{ name: "my-cli", target: "mycli" }`
365///   → renders `binary "my-cli", target: "mycli"`. The `target:` form is
366///   the Homebrew Ruby cask DSL rename: install the symlink at
367///   `/usr/local/bin/<target>` instead of `/usr/local/bin/<name>`.
368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
369#[serde(untagged)]
370pub enum HomebrewCaskBinary {
371    /// Bare binary name. Equivalent to `{ name: "<n>", target: None }`.
372    Name(String),
373    /// Structured `{ name, target }` rename form.
374    WithTarget {
375        /// Path inside the .app bundle (e.g. `"my-cli"`).
376        name: String,
377        /// Optional rename target — the symlink name in `/usr/local/bin`.
378        /// When `None`, the symlink uses `name`.
379        #[serde(skip_serializing_if = "Option::is_none")]
380        target: Option<String>,
381    },
382}
383
384impl HomebrewCaskBinary {
385    /// The binary name (the path inside the .app bundle).
386    pub fn name(&self) -> &str {
387        match self {
388            Self::Name(n) => n,
389            Self::WithTarget { name, .. } => name,
390        }
391    }
392    /// The optional rename target. `None` for bare-string entries and for
393    /// `{ name, target }` objects without `target` set.
394    pub fn target(&self) -> Option<&str> {
395        match self {
396            Self::Name(_) => None,
397            Self::WithTarget { target, .. } => target.as_deref(),
398        }
399    }
400}
401
402/// Cask dependency (on another cask or formula).
403#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
404#[serde(default, deny_unknown_fields)]
405pub struct HomebrewCaskDependencyEntry {
406    /// Dependent cask name.
407    pub cask: Option<String>,
408    /// Dependent formula name.
409    pub formula: Option<String>,
410}
411
412/// Cask conflict (with another cask or formula).
413#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
414#[serde(default, deny_unknown_fields)]
415pub struct HomebrewCaskConflictEntry {
416    /// Conflicting cask name.
417    pub cask: Option<String>,
418    /// Conflicting formula name (deprecated by Homebrew).
419    pub formula: Option<String>,
420}
421
422/// Auto-generate shell completions from an executable.
423#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
424#[serde(default, deny_unknown_fields)]
425pub struct HomebrewCaskGeneratedCompletions {
426    /// Binary to generate completions from.
427    pub executable: Option<String>,
428    /// Arguments to pass to the executable.
429    pub args: Option<Vec<String>>,
430    /// Base name for completion files.
431    pub base_name: Option<String>,
432    /// Shell completion framework type (arg, clap, click, cobra, flag, none, typer).
433    pub shell_parameter_format: Option<String>,
434    /// Target shells (bash, zsh, fish, pwsh).
435    pub shells: Option<Vec<String>>,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
439#[serde(default, deny_unknown_fields)]
440pub struct ScoopConfig {
441    /// Unified repository config with branch, token, PR, git SSH support.
442    /// (Replaces the legacy `bucket: BucketConfig` owner/name-only form.)
443    pub repository: Option<RepositoryConfig>,
444    /// Commit author with optional signing.
445    pub commit_author: Option<CommitAuthorConfig>,
446    /// Override the manifest name (default: crate name).
447    pub name: Option<String>,
448    /// Subdirectory in the bucket repo for manifest placement.
449    pub directory: Option<String>,
450    /// Short description of the package (shown in `scoop info`).
451    pub description: Option<String>,
452    /// SPDX license identifier (e.g., "MIT", "Apache-2.0").
453    pub license: Option<String>,
454    /// Project homepage URL. Falls back to the GitHub-derived URL when unset.
455    pub homepage: Option<String>,
456    /// Data paths persisted between Scoop updates.
457    pub persist: Option<Vec<String>>,
458    /// Application dependencies (other Scoop packages).
459    pub depends: Option<Vec<String>>,
460    /// Commands to run before installation.
461    pub pre_install: Option<Vec<String>>,
462    /// Commands to run after installation.
463    pub post_install: Option<Vec<String>>,
464    /// Start menu shortcuts as `[executable, label]` pairs.
465    pub shortcuts: Option<Vec<Vec<String>>>,
466    /// Skip publishing the manifest.  `"true"` always skips; `"auto"` skips
467    /// for prerelease versions. Accepts bool or template string.
468    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
469    pub skip_upload: Option<StringOrBool>,
470    /// Custom commit message template.
471    pub commit_msg_template: Option<String>,
472    // Use the structured `commit_author: { name, email, signing }` form for
473    // commit author identity (legacy flat `commit_author_name` /
474    // `commit_author_email` fields are not accepted).
475    /// Build IDs filter: only include artifacts whose `id` is in this list.
476    pub ids: Option<Vec<String>>,
477    /// Custom URL template for download URLs (overrides release URL).
478    pub url_template: Option<String>,
479    /// Artifact selection: "archive" (default), "msi", or "nsis".
480    #[serde(rename = "use")]
481    pub use_artifact: Option<String>,
482    /// amd64 microarchitecture variant filter (e.g. "v1", "v2", "v3", "v4").
483    /// Only artifacts matching this variant are included. Default: "v1".
484    pub amd64_variant: Option<String>,
485    /// Override whether this publisher failing should fail the overall release.
486    ///
487    /// Default: `false` — a failure here is logged but does not abort the release.
488    /// Set to `true` to fail the release on any error.
489    #[serde(default, skip_serializing_if = "Option::is_none")]
490    pub required: Option<bool>,
491    /// Template-conditional gate: when the rendered result is falsy
492    /// (`"false"` / `"0"` / `"no"` / empty), the Scoop publisher is
493    /// skipped. Render failure hard-errors. Config key: `scoop[].if:`.
494    #[serde(rename = "if")]
495    pub if_condition: Option<String>,
496    /// When `true`, a triggered rollback leaves this publisher's work in
497    /// place rather than attempting to undo it. Default `false`.
498    pub retain_on_rollback: Option<bool>,
499}
500
501// `TapConfig` / `BucketConfig` (legacy {owner, name}-only repo types) live
502// nowhere — every publisher now carries `repository: RepositoryConfig`
503// with the broader feature set (token / branch / git SSH / pull_request).