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