Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 1 addition & 112 deletions Library/Homebrew/api/cask.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require "api"
require "api/source_download"
require "download_queue"
require "api/cask/cask_struct_generator"

module Homebrew
module API
Expand Down Expand Up @@ -145,118 +146,6 @@ def self.write_names(regenerate: false)

Homebrew::API.write_names_file!(all_casks.keys, "cask", regenerate:)
end

# 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], ignore_types: T::Boolean).returns(CaskStruct) }
def self.generate_cask_struct_hash(hash, 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"] = Array(hash["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
126 changes: 126 additions & 0 deletions Library/Homebrew/api/cask/cask_struct_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,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)
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.

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?
Comment on lines +68 to +70
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.

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
Comment on lines +1 to +126
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.
59 changes: 59 additions & 0 deletions Library/Homebrew/api/cask_struct.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,65 @@ def caveats(appdir:)
deep_remove_placeholders(raw_caveats, appdir.to_s)
end

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?
Comment on lines +112 to +114
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.
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
Comment on lines +96 to +153
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.

private

const :raw_artifacts, T::Array[ArtifactArgs], default: []
Expand Down
Loading
Loading