Skip to content

Conversation

@Rylan12
Copy link
Member

@Rylan12 Rylan12 commented Feb 2, 2026

Follow-up to #21456

This PR updates the /api/internal/cask.TAG.json API to contain serialized cask structs, like we did with formulae.

Copilot AI review requested due to automatic review settings February 2, 2026 16:23
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request updates the internal cask API to serialize CaskStruct instances, following the same pattern established for formulae in PR #21456. The changes extract utility methods from FormulaStruct to the Utils module so they can be shared with CaskStruct, move the cask struct generation logic into a dedicated module, and implement serialization/deserialization methods for CaskStruct.

Changes:

  • Refactored deep symbol stringify/unstringify and compact_blank methods from FormulaStruct to Utils module
  • Added serialize/deserialize methods to CaskStruct with artifact handling
  • Extracted generate_cask_struct_hash from API::Cask into new CaskStructGenerator module
  • Updated internal cask API generation to serialize cask structs before writing to JSON

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
Library/Homebrew/utils.rb Adds deep_stringify_symbols, deep_unstringify_symbols, and deep_compact_blank utility methods moved from FormulaStruct
Library/Homebrew/test/utils_spec.rb Moves tests for the utility methods from formula_struct_spec.rb to utils_spec.rb
Library/Homebrew/api/formula_struct.rb Removes utility methods that were moved to Utils and updates references to use Utils module
Library/Homebrew/test/api/formula_struct_spec.rb Removes tests that were moved to utils_spec.rb
Library/Homebrew/api/cask_struct.rb Adds serialize, deserialize, and deserialize_artifact_args methods for CaskStruct serialization
Library/Homebrew/api/cask/cask_struct_generator.rb New module containing generate_cask_struct_hash method moved from API::Cask
Library/Homebrew/api/cask.rb Removes generate_cask_struct_hash method and adds require for CaskStructGenerator
Library/Homebrew/cask/cask_loader.rb Updates reference to use CaskStructGenerator module
Library/Homebrew/dev-cmd/generate-cask-api.rb Updates to serialize CaskStruct instances and adds tap_git_head to JSON output

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

# so it must never fail with older JSON API versions
sig { params(hash: T::Hash[String, T.untyped], bottle_tag: Utils::Bottles::Tag, ignore_types: T::Boolean).returns(CaskStruct) }
def generate_cask_struct_hash(hash, bottle_tag: Utils::Bottles.tag, ignore_types: false)
hash = Homebrew::API.merge_variations(hash).dup.deep_symbolize_keys.transform_keys(&:to_s)
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bottle_tag parameter is accepted by this method but not passed to merge_variations. This differs from the FormulaStructGenerator implementation which passes it correctly. The bottle_tag parameter should be passed to ensure variations are correctly merged for the target platform.

Suggested change
hash = Homebrew::API.merge_variations(hash).dup.deep_symbolize_keys.transform_keys(&:to_s)
hash = Homebrew::API.merge_variations(hash, bottle_tag: bottle_tag).dup.deep_symbolize_keys.transform_keys(&:to_s)

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +70
# Pass an empty block to artifacts like postflight that can't be loaded from the API,
# but need to be set to something.
next [key, [], {}, -> {}] if artifact[key].nil?
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Artifacts with nil values are assigned an empty lambda block which cannot be serialized to JSON. When the CaskStruct's serialize method is called (as done in generate-cask-api.rb), this lambda will cause JSON serialization to fail. Consider replacing the lambda with nil or filtering out the block element before serialization, similar to how FormulaStruct handles special cases with SKIP_SERIALIZATION.

Suggested change
# Pass an empty block to artifacts like postflight that can't be loaded from the API,
# but need to be set to something.
next [key, [], {}, -> {}] if artifact[key].nil?
# For artifacts like postflight that can't be loaded from the API but need a block slot,
# use nil for the block to keep the structure consistent and JSON-serializable.
next [key, [], {}, nil] if artifact[key].nil?

Copilot uses AI. Check for mistakes.
Comment on lines +112 to +114
# Items that don't follow the `hash["foo_present"] = hash["foo_args"].present?` pattern are overridden below
PREDICATES.each do |name|
hash["#{name}_present"] = hash["#{name}_args"].present?
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deserialize method attempts to reconstruct predicate_present values by checking for corresponding _args fields, but not all predicates have _args fields. For example, :auto_updates is a boolean field (not auto_updates_args), :caveats maps to raw_caveats (not caveats_args), :conflicts maps to conflicts_with_args (not conflicts_args), :desc is a string field (not desc_args), and :homepage is a string field (not homepage_args). This will cause these predicate_present values to be incorrectly set to false (or nil.present? = false) even when the actual data is present. Special handling is needed for these predicates, similar to how FormulaStruct handles bottle, stable, and head predicates with custom logic.

Suggested change
# Items that don't follow the `hash["foo_present"] = hash["foo_args"].present?` pattern are overridden below
PREDICATES.each do |name|
hash["#{name}_present"] = hash["#{name}_args"].present?
# Compute predicate *_present flags based on their actual backing fields.
PREDICATES.each do |name|
source_value =
case name
when :auto_updates
hash["auto_updates"]
when :caveats
hash["raw_caveats"]
when :conflicts, :conflicts_with
hash["conflicts_with_args"]
when :desc
hash["desc"]
when :homepage
hash["homepage"]
else
hash["#{name}_args"]
end
hash["#{name}_present"] = source_value.present?

Copilot uses AI. Check for mistakes.
Comment on lines +96 to +153
sig { params(bottle_tag: ::Utils::Bottles::Tag).returns(T::Hash[String, T.untyped]) }
def serialize(bottle_tag: ::Utils::Bottles.tag)
hash = self.class.decorator.all_props.filter_map do |prop|
next if PREDICATES.any? { |predicate| prop == :"#{predicate}_present" }

[prop.to_s, send(prop)]
end.to_h

hash = ::Utils.deep_stringify_symbols(hash)
::Utils.deep_compact_blank(hash)
end

sig { params(hash: T::Hash[String, T.untyped]).returns(CaskStruct) }
def self.deserialize(hash)
hash = ::Utils.deep_unstringify_symbols(hash)

# Items that don't follow the `hash["foo_present"] = hash["foo_args"].present?` pattern are overridden below
PREDICATES.each do |name|
hash["#{name}_present"] = hash["#{name}_args"].present?
end

hash["raw_artifacts"] = if (raw_artifacts = hash["raw_artifacts"])
raw_artifacts.map { |artifact| deserialize_artifact_args(artifact) }
end

from_hash(hash)
end

# Format artifact args pairs into proper [key, args, kwargs, block] format since serialization removed blanks.
sig {
params(
args: T.any(
[Symbol],
[Symbol, T::Array[T.anything]],
[Symbol, T::Hash[Symbol, T.anything]],
[Symbol, T.proc.void],
[Symbol, T::Array[T.anything], T::Hash[Symbol, T.anything]],
[Symbol, T::Array[T.anything], T.proc.void],
[Symbol, T::Hash[Symbol, T.anything], T.proc.void],
[Symbol, T::Array[T.anything], T::Hash[Symbol, T.anything], T.proc.void],
),
).returns(ArtifactArgs)
}
def self.deserialize_artifact_args(args)
args = case args
in [key] then [key, [], {}, nil]
in [key, Array => array] then [key, array, {}, nil]
in [key, Hash => hash] then [key, [], hash, nil]
in [key, Proc => block] then [key, [], {}, block]
in [key, Array => array, Hash => hash] then [key, array, hash, nil]
in [key, Array => array, Proc => block] then [key, array, {}, block]
in [key, Hash => hash, Proc => block] then [key, [], hash, block]
in [key, Array => array, Hash => hash, Proc => block] then [key, array, hash, block]
end

# The case above is exhaustive so args will never be nil, but sorbet cannot infer that.
T.must(args)
end
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The serialize and deserialize methods are being added without corresponding test coverage. The FormulaStruct has comprehensive tests in test/api/formula_struct_spec.rb, establishing a convention that serialize/deserialize methods should be tested. Consider adding tests similar to those in formula_struct_spec.rb to verify the serialization/deserialization logic, especially for edge cases like artifacts with empty blocks and predicates with various field types.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +126
# typed: strict
# frozen_string_literal: true

module Homebrew
module API
module Cask
# Methods for generating CaskStruct instances from API data.
module CaskStructGenerator
module_function

# NOTE: this will be used to load installed cask JSON files,
# so it must never fail with older JSON API versions
sig { params(hash: T::Hash[String, T.untyped], bottle_tag: Utils::Bottles::Tag, ignore_types: T::Boolean).returns(CaskStruct) }
def generate_cask_struct_hash(hash, bottle_tag: Utils::Bottles.tag, ignore_types: false)
hash = Homebrew::API.merge_variations(hash).dup.deep_symbolize_keys.transform_keys(&:to_s)

hash["conflicts_with_args"] = hash["conflicts_with"]

hash["container_args"] = hash["container"]&.to_h do |key, value|
next [key, value.to_sym] if key == :type

[key, value]
end

hash["depends_on_args"] = hash["depends_on"]&.to_h do |key, value|
# Arch dependencies are encoded like `{ type: :intel, bits: 64 }`
# but `depends_on arch:` only accepts `:intel` or `:arm64`
if key == :arch
next [:arch, :intel] if value.first[:type] == "intel"

next [:arch, :arm64]
end

next [key, value] if key != :macos

dep_type = value.keys.first
if dep_type == :==
version_symbols = value[dep_type].filter_map do |version|
MacOSVersion::SYMBOLS.key(version)
end
next [key, version_symbols.presence]
end

version_symbol = value[dep_type].first
version_symbol = MacOSVersion::SYMBOLS.key(version_symbol)
version_dep = "#{dep_type} :#{version_symbol}" if version_symbol
[key, version_dep]
end&.compact_blank

if (deprecate_args = hash["deprecate_args"])
deprecate_args = deprecate_args.dup
deprecate_args[:because] =
DeprecateDisable.to_reason_string_or_symbol(deprecate_args[:because], type: :cask)
hash["deprecate_args"] = deprecate_args
end

if (disable_args = hash["disable_args"])
disable_args = disable_args.dup
disable_args[:because] = DeprecateDisable.to_reason_string_or_symbol(disable_args[:because], type: :cask)
hash["disable_args"] = disable_args
end

hash["names"] = hash["name"]

hash["raw_artifacts"] = hash.fetch("artifacts", []).map do |artifact|
key = artifact.keys.first

# Pass an empty block to artifacts like postflight that can't be loaded from the API,
# but need to be set to something.
next [key, [], {}, -> {}] if artifact[key].nil?

args = artifact[key]
kwargs = if args.last.is_a?(Hash)
args.pop
else
{}
end
[key, args, kwargs, nil]
end

hash["raw_caveats"] = hash["caveats"]

hash["renames"] = hash["rename"]&.map do |operation|
[operation[:from], operation[:to]]
end

hash["ruby_source_checksum"] = {
sha256: hash.dig("ruby_source_checksum", :sha256),
}

hash["sha256"] = :no_check if hash["sha256"] == "no_check"

hash["tap_string"] = hash["tap"]

hash["url_args"] = [hash["url"]]

hash["url_kwargs"] = hash["url_specs"]&.to_h do |key, value|
value = case key
when :user_agent
Utils.convert_to_string_or_symbol(value)
when :using
value.to_sym
else
value
end

[key, value]
end&.compact_blank

# Should match CaskStruct::PREDICATES
hash["auto_updates_present"] = hash["auto_updates"].present?
hash["caveats_present"] = hash["caveats"].present?
hash["conflicts_present"] = hash["conflicts_with"].present?
hash["container_present"] = hash["container"].present?
hash["depends_on_present"] = hash["depends_on_args"].present?
hash["deprecate_present"] = hash["deprecate_args"].present?
hash["desc_present"] = hash["desc"].present?
hash["disable_present"] = hash["disable_args"].present?
hash["homepage_present"] = hash["homepage"].present?

CaskStruct.from_hash(hash, ignore_types:)
end
end
end
end
end
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CaskStructGenerator module is being added without corresponding test coverage. The FormulaStructGenerator has comprehensive tests in test/api/formula/formula_struct_generator_spec.rb, establishing a convention that struct generators should be tested. Consider adding tests similar to those in formula_struct_generator_spec.rb to verify the generation logic, especially for complex transformations like arch dependencies, macos version handling, and artifact processing.

Copilot uses AI. Check for mistakes.
Copy link
Member

@MikeMcQuaid MikeMcQuaid left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good so far!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants