Merge pull request #57 from Homebrew/modularisation

Package Modularisation
This commit is contained in:
Mike McQuaid
2026-05-29 13:39:55 +01:00
committed by GitHub
224 changed files with 2934 additions and 1767 deletions
+1 -1
View File
@@ -78,4 +78,4 @@ jobs:
- name: Run BrewUILint
run: |
BREWUILINT="$(swift build --package-path Tools/BrewUILint -c release --show-bin-path)/BrewUILint"
find Brew -name '*.swift' -print0 | xargs -0 "$BREWUILINT"
find Brew Sources -name '*.swift' -print0 | xargs -0 "$BREWUILINT"
+1
View File
@@ -3,6 +3,7 @@
.ai/scratchpad.md
# progress is per-developer local state
.ai/progress.md
.ai/plans/
# macOS
.DS_Store
+32
View File
@@ -19,11 +19,18 @@ disabled_rules:
- unneeded_synthesized_initializer
- vertical_whitespace
- void_return
# Fires on the common pattern of putting a // swiftlint:disable:next
# comment between a doc comment and the declaration it documents.
# The combined "explain why you're suppressing a rule" pattern is more
# valuable than the rule catches.
- orphaned_doc_comment
included:
- Brew
- BrewTests
- BrewUITests
- Sources
- Tests
excluded:
- .build
@@ -49,3 +56,28 @@ identifier_name:
- x
- y
- z
custom_rules:
unchecked_sendable:
name: "Unchecked Sendable"
regex: '@unchecked\s+Sendable'
message: "@unchecked Sendable bypasses concurrency checking. Justify with a comment explaining why this is safe, then add // swiftlint:disable:next unchecked_sendable"
severity: error
nonisolated_unsafe:
name: "Nonisolated Unsafe"
regex: 'nonisolated\(unsafe\)'
message: "nonisolated(unsafe) disables data-race checking. Justify with a comment explaining the synchronisation mechanism, then add // swiftlint:disable:next nonisolated_unsafe"
severity: error
preconcurrency_import:
name: "Preconcurrency Import"
regex: '@preconcurrency\s+import'
message: "@preconcurrency import suppresses isolation checking for the imported module. Document which APIs from this module are being bridged and why."
severity: error
assume_isolated:
name: "Assume Isolated"
regex: '\.assumeIsolated\s*\{'
message: "MainActor.assumeIsolated asserts isolation without compile-time verification. Justify with a comment explaining why the assumption holds at this call site."
severity: error
+3 -2
View File
@@ -83,10 +83,11 @@ When you change Swift sources or anything that affects Swift formatting or linti
2. `mint run swiftlint lint --strict`
3. BrewUILint (matches CI; first build is slow because it compiles `swift-syntax`):
- `swift build --package-path Tools/BrewUILint -c release --enable-experimental-prebuilts`
- `"$(swift build --package-path Tools/BrewUILint -c release --show-bin-path)/BrewUILint" $(find Brew -name '*.swift')`
- `"$(swift build --package-path Tools/BrewUILint -c release --show-bin-path)/BrewUILint" $(find Brew Sources -name '*.swift')`
- BrewUILint lints `Brew` + `Sources` (the whole production tree, all packages); `Tests` is excluded. It must run over all files in one pass — the `nonisolated`-extension rule needs to see every `nonisolated` type declaration to flag a bad extension on it.
- Keep `Tools/BrewUILint/.build` between runs; deleting it forces a full rebuild (~2+ minutes).
The pre-commit hook formats and lints **staged** files only; these commands validate the **whole** tree like CI and catch drift in unstaged paths.
The pre-commit hook formats and lints **staged** Swift files (SwiftFormat/SwiftLint) and additionally runs **BrewUILint over the whole tree** on every commit; these manual commands validate the whole tree like CI and catch drift in unstaged paths.
---
+85
View File
@@ -9,6 +9,16 @@
/* Begin PBXBuildFile section */
EE2FB0362F653F89004AE92A /* Brew-Unit.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = EE2FB0352F653F89004AE92A /* Brew-Unit.xctestplan */; };
EE2FB0382F654024004AE92A /* Brew-UI.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = EE2FB0372F654024004AE92A /* Brew-UI.xctestplan */; };
EEAA110100000000000000B1 /* BrewCore in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100100000000000000C1 /* BrewCore */; };
EEAA110200000000000000B2 /* BrewUIComponents in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100200000000000000C2 /* BrewUIComponents */; };
EEAA110300000000000000B3 /* BrewCLI in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100300000000000000C3 /* BrewCLI */; };
EEAA110400000000000000B4 /* BrewNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100400000000000000C4 /* BrewNetworking */; };
EEAA110500000000000000B5 /* BrewRepositoryInterfaces in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100500000000000000C5 /* BrewRepositoryInterfaces */; };
EEAA110600000000000000B6 /* BrewRepositories in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100600000000000000C6 /* BrewRepositories */; };
EEAA110700000000000000B7 /* BrewFeatureConsole in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100700000000000000C7 /* BrewFeatureConsole */; };
EEAA110800000000000000B8 /* BrewFeatureInstalled in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100800000000000000C8 /* BrewFeatureInstalled */; };
EEAA110900000000000000B9 /* BrewFeatureDiscover in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100900000000000000C9 /* BrewFeatureDiscover */; };
EEAA110A00000000000000BA /* BrewAppEnvironment in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100A00000000000000CA /* BrewAppEnvironment */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -61,6 +71,16 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
EEAA110100000000000000B1 /* BrewCore in Frameworks */,
EEAA110200000000000000B2 /* BrewUIComponents in Frameworks */,
EEAA110300000000000000B3 /* BrewCLI in Frameworks */,
EEAA110400000000000000B4 /* BrewNetworking in Frameworks */,
EEAA110500000000000000B5 /* BrewRepositoryInterfaces in Frameworks */,
EEAA110600000000000000B6 /* BrewRepositories in Frameworks */,
EEAA110700000000000000B7 /* BrewFeatureConsole in Frameworks */,
EEAA110800000000000000B8 /* BrewFeatureInstalled in Frameworks */,
EEAA110900000000000000B9 /* BrewFeatureDiscover in Frameworks */,
EEAA110A00000000000000BA /* BrewAppEnvironment in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -134,6 +154,16 @@
);
name = Brew;
packageProductDependencies = (
EEAA100100000000000000C1 /* BrewCore */,
EEAA100200000000000000C2 /* BrewUIComponents */,
EEAA100300000000000000C3 /* BrewCLI */,
EEAA100400000000000000C4 /* BrewNetworking */,
EEAA100500000000000000C5 /* BrewRepositoryInterfaces */,
EEAA100600000000000000C6 /* BrewRepositories */,
EEAA100700000000000000C7 /* BrewFeatureConsole */,
EEAA100800000000000000C8 /* BrewFeatureInstalled */,
EEAA100900000000000000C9 /* BrewFeatureDiscover */,
EEAA100A00000000000000CA /* BrewAppEnvironment */,
);
productName = Brew;
productReference = EEC39EBF2F5AB4B900269514 /* Brew.app */;
@@ -219,6 +249,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
EEAA00012F70000000000001 /* XCLocalSwiftPackageReference "Tools/BrewUILint" */,
EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */,
);
preferredProjectObjectVersion = 77;
productRefGroup = EEC39EC02F5AB4B900269514 /* Products */;
@@ -610,6 +641,10 @@
isa = XCLocalSwiftPackageReference;
relativePath = Tools/BrewUILint;
};
EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */ = {
isa = XCLocalSwiftPackageReference;
relativePath = .;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -618,6 +653,56 @@
package = EEAA00012F70000000000001 /* XCLocalSwiftPackageReference "Tools/BrewUILint" */;
productName = "plugin:BrewUILintPlugin";
};
EEAA100100000000000000C1 /* BrewCore */ = {
isa = XCSwiftPackageProductDependency;
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
productName = BrewCore;
};
EEAA100200000000000000C2 /* BrewUIComponents */ = {
isa = XCSwiftPackageProductDependency;
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
productName = BrewUIComponents;
};
EEAA100300000000000000C3 /* BrewCLI */ = {
isa = XCSwiftPackageProductDependency;
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
productName = BrewCLI;
};
EEAA100400000000000000C4 /* BrewNetworking */ = {
isa = XCSwiftPackageProductDependency;
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
productName = BrewNetworking;
};
EEAA100500000000000000C5 /* BrewRepositoryInterfaces */ = {
isa = XCSwiftPackageProductDependency;
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
productName = BrewRepositoryInterfaces;
};
EEAA100600000000000000C6 /* BrewRepositories */ = {
isa = XCSwiftPackageProductDependency;
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
productName = BrewRepositories;
};
EEAA100700000000000000C7 /* BrewFeatureConsole */ = {
isa = XCSwiftPackageProductDependency;
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
productName = BrewFeatureConsole;
};
EEAA100800000000000000C8 /* BrewFeatureInstalled */ = {
isa = XCSwiftPackageProductDependency;
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
productName = BrewFeatureInstalled;
};
EEAA100900000000000000C9 /* BrewFeatureDiscover */ = {
isa = XCSwiftPackageProductDependency;
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
productName = BrewFeatureDiscover;
};
EEAA100A00000000000000CA /* BrewAppEnvironment */ = {
isa = XCSwiftPackageProductDependency;
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
productName = BrewAppEnvironment;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = EEC39EB72F5AB4B900269514 /* Project object */;
+35 -8
View File
@@ -5,35 +5,62 @@
// Created by Graeme Arthur on 6/3/2026.
//
import BrewAppEnvironment
import BrewCLI
import BrewCore
import BrewFeatureConsole
import BrewNetworking
import BrewRepositories
import BrewRepositoryInterfaces
import BrewUIComponents
import SwiftUI
@main
struct BrewApp: App {
private let commandCenter: SerialBrewCommandCenter
private let commandFactory: LiveBrewMutatingCommandFactory
private let installedInventoryCache: InstalledInventoryCache
private let catalogueCache: CatalogueCache
private let installedPackagesRepository: BrewInstalledPackagesRepository
private let commandJobsRepository: BrewCommandJobsRepository
private let installedDependentsRepository: BrewInstalledDependentsRepository
private let catalogueRepository: BrewCatalogueRepository
private let discoverPackagesRepository: BrewDiscoverPackagesRepository
init() {
installedInventoryCache = InstalledInventoryCache()
catalogueCache = CatalogueCache()
commandCenter = SerialBrewCommandCenter(executionContext: .live())
let inventoryCache = InstalledInventoryCache()
let catalogue = CatalogueCache()
let center = SerialBrewCommandCenter(executionContext: .live())
let apiClient = URLSessionBrewAPIClient.live()
let catalogueRepo = BrewCatalogueRepository(apiClient: apiClient, cache: catalogue)
installedInventoryCache = inventoryCache
catalogueCache = catalogue
commandCenter = center
commandFactory = LiveBrewMutatingCommandFactory()
installedPackagesRepository = BrewInstalledPackagesRepository.live(
cache: installedInventoryCache,
commandCenter: commandCenter,
cache: inventoryCache,
commandCenter: center,
)
commandJobsRepository = BrewCommandJobsRepository(commandCenter: center)
installedDependentsRepository = BrewInstalledDependentsRepository(cache: inventoryCache)
catalogueRepository = catalogueRepo
discoverPackagesRepository = BrewDiscoverPackagesRepository(
apiClient: apiClient,
catalogueRepository: catalogueRepo,
)
commandJobsRepository = BrewCommandJobsRepository(commandCenter: commandCenter)
}
var body: some Scene {
WindowGroup {
MainWindowView()
.environment(\.brewCommandCenter, commandCenter)
.environment(\.installedInventoryCache, installedInventoryCache)
.environment(\.catalogueCache, catalogueCache)
.environment(\.mutatingCommandFactory, commandFactory)
.environment(\.installedPackagesRepository, installedPackagesRepository)
.environment(\.commandJobsRepository, commandJobsRepository)
.environment(\.installedDependentsRepository, installedDependentsRepository)
.environment(\.catalogueRepository, catalogueRepository)
.environment(\.discoverPackagesRepository, discoverPackagesRepository)
.task { await catalogueCache.prepare() }
.task { await installedPackagesRepository.load() }
}
@@ -1,4 +1,9 @@
import AppKit
import BrewAppEnvironment
import BrewFeatureConsole
import BrewFeatureDiscover
import BrewFeatureInstalled
import BrewUIComponents
import SwiftUI
struct MainWindowView: View {
@@ -55,9 +60,15 @@ struct MainWindowView: View {
}
}
#Preview {
MainWindowView()
.environment(\.brewCommandCenter, AppPreviewSupport.commandCenter)
.environment(\.installedInventoryCache, AppPreviewSupport.installedInventoryCache)
.environment(\.installedPackagesRepository, AppPreviewSupport.makeInstalledPackagesRepository())
}
#if DEBUG
import BrewRepositoryInterfaces
#Preview {
MainWindowView()
.environment(\.brewCommandCenter, PreviewSupport.commandCenter)
.environment(\.installedPackagesRepository, PreviewSupport.makeInstalledPackagesRepository())
.environment(\.discoverPackagesRepository, PreviewSupport.makeDiscoverPackagesRepository())
.environment(\.catalogueRepository, PreviewSupport.makeDiscoverCatalogueRepository())
.environment(\.installedDependentsRepository, PreviewSupport.makeInstalledDependentsRepository())
}
#endif
-24
View File
@@ -1,24 +0,0 @@
//
// BrewPackage.swift
// Brew
//
import Foundation
nonisolated struct BrewPackage: Identifiable, Hashable {
let name: String
let displayName: String
let kind: HomebrewPackageKind
var description: String
var homepage: String
var latestVersion: String
var dependencies: [HomebrewPackageID]
var id: HomebrewPackageID {
reference
}
var reference: HomebrewPackageID {
HomebrewPackageID(package: self)
}
}
-57
View File
@@ -1,57 +0,0 @@
//
// DiscoveryBrewPackage.swift
// Brew
//
import Foundation
/// Domain output for Discover top package sections.
nonisolated struct DiscoverTopPackagesSnapshot: Equatable {
let topFormulae: [DiscoveryBrewPackage]
let topCasks: [DiscoveryBrewPackage]
}
nonisolated struct DiscoveryBrewPackage: Identifiable, Equatable, Hashable {
var package: BrewPackage
var thirtyDayInstallCount: Int
var name: String {
package.name
}
var displayName: String {
package.displayName
}
var kind: HomebrewPackageKind {
package.kind
}
var description: String {
get { package.description }
set { package.description = newValue }
}
var homepage: String {
get { package.homepage }
set { package.homepage = newValue }
}
var latestVersion: String {
get { package.latestVersion }
set { package.latestVersion = newValue }
}
var dependencies: [HomebrewPackageID] {
get { package.dependencies }
set { package.dependencies = newValue }
}
var id: HomebrewPackageID {
package.id
}
var reference: HomebrewPackageID {
HomebrewPackageID(package: package)
}
}
-52
View File
@@ -1,52 +0,0 @@
//
// InstalledBrewPackage.swift
// Brew
//
import Foundation
nonisolated struct InstalledBrewPackage: Identifiable, Hashable {
var package: BrewPackage
var installedVersions: [String]
var outdated: Bool
var name: String {
package.name
}
var displayName: String {
package.displayName
}
var kind: HomebrewPackageKind {
package.kind
}
var description: String {
get { package.description }
set { package.description = newValue }
}
var homepage: String {
get { package.homepage }
set { package.homepage = newValue }
}
var latestVersion: String {
get { package.latestVersion }
set { package.latestVersion = newValue }
}
var dependencies: [HomebrewPackageID] {
get { package.dependencies }
set { package.dependencies = newValue }
}
var id: HomebrewPackageID {
package.id
}
var reference: HomebrewPackageID {
HomebrewPackageID(package: package)
}
}
-358
View File
@@ -1,358 +0,0 @@
//
// AppPreviewSupport.swift
// Brew
//
import Foundation
nonisolated enum AppPreviewSupport {
static let commandCenter = PreviewBrewCommandCenter()
static let installedInventoryCache = InstalledInventoryCache()
static let discoverTopPackagesSnapshot = DiscoverTopPackagesSnapshot(
topFormulae: [
DiscoveryBrewPackage(
package: BrewPackage(
name: "git",
displayName: "git",
kind: .formula,
description: "",
homepage: "",
latestVersion: "",
dependencies: [],
),
thirtyDayInstallCount: 420_000,
),
DiscoveryBrewPackage(
package: BrewPackage(
name: "node",
displayName: "node",
kind: .formula,
description: "",
homepage: "",
latestVersion: "",
dependencies: [],
),
thirtyDayInstallCount: 360_000,
),
],
topCasks: [
DiscoveryBrewPackage(
package: BrewPackage(
name: "iterm2",
displayName: "iterm2",
kind: .cask,
description: "",
homepage: "",
latestVersion: "",
dependencies: [],
),
thirtyDayInstallCount: 180_000,
),
DiscoveryBrewPackage(
package: BrewPackage(
name: "docker",
displayName: "docker",
kind: .cask,
description: "",
homepage: "",
latestVersion: "",
dependencies: [],
),
thirtyDayInstallCount: 160_000,
),
],
)
/// A single representative discovery package for previews that only need one row's worth of data.
static let discoverPreviewPackage = DiscoveryBrewPackage(
package: BrewPackage(
name: "git",
displayName: "git",
kind: .formula,
description: "Distributed revision control system",
homepage: "https://git-scm.com",
latestVersion: "2.46.1",
dependencies: [],
),
thirtyDayInstallCount: 420_000,
)
static let discoverFormulaeCatalogue: [BrewPackage] = [
BrewPackage(
name: "git",
displayName: "git",
kind: .formula,
description: "Distributed revision control system",
homepage: "https://git-scm.com",
latestVersion: "2.46.1",
dependencies: [],
),
BrewPackage(
name: "node",
displayName: "node",
kind: .formula,
description: "JavaScript runtime built on V8",
homepage: "https://nodejs.org",
latestVersion: "22.14.0",
dependencies: [],
),
]
static let discoverCasksCatalogue: [BrewPackage] = [
BrewPackage(
name: "iterm2",
displayName: "iTerm2",
kind: .cask,
description: "Terminal emulator and shell replacement",
homepage: "https://iterm2.com",
latestVersion: "3.5.0",
dependencies: [],
),
BrewPackage(
name: "docker",
displayName: "Docker",
kind: .cask,
description: "Build and share containerized applications",
homepage: "https://www.docker.com",
latestVersion: "4.39.0",
dependencies: [],
),
]
static let outdatedFormula = InstalledBrewPackage(
package: BrewPackage(
name: "git",
displayName: "git",
kind: .formula,
description: "Distributed revision control system.",
homepage: "https://git-scm.com",
latestVersion: "2.46.1",
dependencies: [.formula(name: "openssl@3"), .formula(name: "pcre2")],
),
installedVersions: ["2.45.0"],
outdated: true,
)
static let currentFormula = InstalledBrewPackage(
package: BrewPackage(
name: "node",
displayName: "node",
kind: .formula,
description: "JavaScript runtime built on V8.",
homepage: "https://nodejs.org",
latestVersion: "22.14.0",
dependencies: [.formula(name: "icu4c@76")],
),
installedVersions: ["22.14.0"],
outdated: false,
)
static let currentCask = InstalledBrewPackage(
package: BrewPackage(
name: "docker",
displayName: "Docker",
kind: .cask,
description: "Build and share containerized applications.",
homepage: "https://www.docker.com",
latestVersion: "4.39.0",
dependencies: [],
),
installedVersions: ["4.39.0"],
outdated: false,
)
static let installedPackages: [InstalledBrewPackage] = [
outdatedFormula,
currentFormula,
InstalledBrewPackage(
package: BrewPackage(
name: "python",
displayName: "python",
kind: .formula,
description: "Interpreted, interactive, object-oriented programming language.",
homepage: "https://www.python.org",
latestVersion: "3.13.2",
dependencies: [.formula(name: "openssl@3"), .formula(name: "sqlite")],
),
installedVersions: ["3.13.2"],
outdated: false,
),
InstalledBrewPackage(
package: BrewPackage(
name: "visual-studio-code",
displayName: "Visual Studio Code",
kind: .cask,
description: "Open-source code editor.",
homepage: "https://code.visualstudio.com",
latestVersion: "1.99.0",
dependencies: [],
),
installedVersions: ["1.99.0"],
outdated: false,
),
currentCask,
]
static let emptyPackages: [InstalledBrewPackage] = []
static let installedDependentsByPackageID: [InstalledBrewPackage.ID: [InstalledBrewPackage]] = [
outdatedFormula.id: [
InstalledBrewPackage(
package: BrewPackage(
name: "gh",
displayName: "gh",
kind: .formula,
description: "GitHub's official command line tool.",
homepage: "https://cli.github.com",
latestVersion: "2.67.0",
dependencies: [.formula(name: "git")],
),
installedVersions: ["2.67.0"],
outdated: false,
),
],
]
static let installedInventoryIDs: Set<InstalledBrewPackage.ID> = Set(
installedPackages.map(\.id) + [
.formula(name: "openssl@3"),
.formula(name: "pcre2"),
.formula(name: "icu4c@76"),
],
)
@MainActor
static func makeInstalledPackagesRepository(
packages: [InstalledBrewPackage] = installedPackages,
) -> BrewInstalledPackagesRepository {
BrewInstalledPackagesRepository.previewLoaded(packages)
}
@MainActor
static func makeInstalledViewModel(
packages: [InstalledBrewPackage] = installedPackages,
) -> InstalledViewModel {
InstalledViewModel(repository: makeInstalledPackagesRepository(packages: packages))
}
@MainActor
static func makeInstalledDependentsRepository() -> any InstalledDependentsRepository {
PreviewInstalledDependentsRepository(
dependentsByPackageID: installedDependentsByPackageID,
)
}
@MainActor
static func makeInstalledInventoryReading() -> any InstalledInventoryReading {
makeInstalledPackagesRepository()
}
@MainActor
static func makeDiscoverPackagesRepository() -> any DiscoverPackagesRepository {
PreviewDiscoverPackagesRepository(snapshot: discoverTopPackagesSnapshot)
}
@MainActor
static func makeDiscoverListRowViewModel(
package: DiscoveryBrewPackage = discoverPreviewPackage,
showsInstallMetrics: Bool = true,
) -> DiscoverListRowViewModel {
DiscoverListRowViewModel(
discoveryPackage: package,
installedRepository: makeInstalledPackagesRepository(),
brewCommandCenter: NoopBrewCommandCenter(executionContext: .noopForTestingAndPreviews()),
showsInstallMetrics: showsInstallMetrics,
)
}
@MainActor
static func makeDiscoverCatalogueRepository() -> any CatalogueRepository {
PreviewDiscoverCatalogueRepository(
formulaCatalogue: discoverFormulaeCatalogue,
caskCatalogue: discoverCasksCatalogue,
)
}
}
actor PreviewBrewCommandCenter: BrewCommandCenter {
func phase(for _: BrewOperationID) async -> BrewOperationPhase {
.idle
}
func phaseByID() async -> [BrewOperationID: BrewOperationPhase] {
[:]
}
func isActive(id _: BrewOperationID) async -> Bool {
false
}
func phaseChanges(for _: BrewOperationID) async -> AsyncStream<BrewOperationPhase> {
AsyncStream<BrewOperationPhase>(bufferingPolicy: .unbounded) { continuation in
continuation.yield(.idle)
continuation.finish()
}
}
func allPhaseChanges() async -> AsyncStream<(BrewOperationID, BrewOperationPhase)> {
AsyncStream<(BrewOperationID, BrewOperationPhase)>(bufferingPolicy: .unbounded) { continuation in
continuation.finish()
}
}
func outputChanges(for _: BrewOperationID) async -> AsyncStream<BrewCommandOutputLine> {
AsyncStream<BrewCommandOutputLine>(bufferingPolicy: .unbounded) { continuation in
continuation.finish()
}
}
func allOutputChanges() async -> AsyncStream<(BrewOperationID, BrewCommandOutputLine)> {
AsyncStream<(BrewOperationID, BrewCommandOutputLine)>(bufferingPolicy: .unbounded) { continuation in
continuation.finish()
}
}
func submit(
id _: BrewOperationID,
command _: any BrewMutatingCommand,
) async throws {}
}
struct PreviewInstalledDependentsRepository: InstalledDependentsRepository {
let dependentsByPackageID: [InstalledBrewPackage.ID: [InstalledBrewPackage]]
func installedDependents(for packageID: InstalledBrewPackage.ID) async -> [InstalledBrewPackage] {
dependentsByPackageID[packageID] ?? []
}
}
struct PreviewDiscoverPackagesRepository: DiscoverPackagesRepository {
let snapshot: DiscoverTopPackagesSnapshot
func loadTopPackages(
limit _: Int,
window _: BrewAnalyticsWindow,
) async throws -> DiscoverTopPackagesSnapshot {
snapshot
}
}
struct PreviewDiscoverCatalogueRepository: CatalogueRepository {
let formulaCatalogue: [BrewPackage]
let caskCatalogue: [BrewPackage]
func package(for reference: HomebrewPackageID) async throws -> BrewPackage? {
let packages = reference.kind == .formula ? formulaCatalogue : caskCatalogue
return packages.first { $0.id == reference }
}
func searchPackages(matching query: String, limit: Int) async throws -> [BrewPackage] {
let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedQuery.isEmpty, limit > 0 else {
return []
}
let matches = (formulaCatalogue + caskCatalogue).filter {
$0.name.localizedCaseInsensitiveContains(trimmedQuery)
|| $0.displayName.localizedCaseInsensitiveContains(trimmedQuery)
}
return Array(matches.prefix(limit))
}
}
@@ -1,21 +0,0 @@
//
// BrewInstalledDependentsRepository.swift
// Brew
//
import Foundation
struct BrewInstalledDependentsRepository: InstalledDependentsRepository {
private let cache: InstalledInventoryCache
init(cache: InstalledInventoryCache) {
self.cache = cache
}
func installedDependents(for packageID: InstalledBrewPackage.ID) async -> [InstalledBrewPackage] {
guard let snapshot = await cache.currentSnapshot() else {
return []
}
return snapshot.graph.installedDependents(for: packageID)
}
}
@@ -1,14 +0,0 @@
//
// CommandJobsRepositoryEnvironment.swift
// Brew
//
import SwiftUI
extension EnvironmentValues {
/// App-scoped projection of command-center operations. Injected at the app root with the live instance;
/// read with `@Environment(\.commandJobsRepository)` from the console root view, which constructs
/// the ``ConsoleViewModel`` that downstream console views consume.
/// The default is an inert placeholder for previews and unscoped subtrees.
@Entry var commandJobsRepository = BrewCommandJobsRepository.placeholder()
}
@@ -1,13 +0,0 @@
//
// InstalledPackagesRepositoryEnvironment.swift
// Brew
//
import SwiftUI
extension EnvironmentValues {
/// App-scoped installed-inventory source of truth. Injected at the nearest common ancestor of the
/// Installed and Discover tabs (``MainWindowView``); read with `@Environment(\.installedPackagesRepository)`.
/// The default is an inert placeholder for previews and unscoped subtrees.
@Entry var installedPackagesRepository = BrewInstalledPackagesRepository.placeholder()
}
@@ -1,11 +0,0 @@
//
// CatalogueCacheEnvironment.swift
// Brew
//
import SwiftUI
extension EnvironmentValues {
/// Inject from ``BrewApp``; otherwise a default cache is created for previews and unscoped subtrees.
@Entry var catalogueCache = CatalogueCache()
}
@@ -1,12 +0,0 @@
//
// BrewCommandCenterEnvironment.swift
// Brew
//
import SwiftUI
extension EnvironmentValues {
/// Mutating `brew` coordinator from the composition root (`BrewApp`).
/// Inject with `.environment(\.brewCommandCenter, center)`; read with `@Environment(\.brewCommandCenter)`.
@Entry var brewCommandCenter: (any BrewCommandCenter) = SerialBrewCommandCenter(executionContext: .live())
}
@@ -1,26 +0,0 @@
//
// BrewCommandExecutionContext.swift
// Brew
//
import Foundation
/// Dependencies for mutating `brew` subprocess work passed into ``BrewMutatingCommand/run(in:)``.
struct BrewCommandExecutionContext {
var commandRunner: BrewCommandRunning
var locator: BrewExecutableLocating
func brewExecutableURL() throws -> URL {
try locator.findBrewExecutable()
}
}
extension BrewCommandExecutionContext {
/// Production wiring: real subprocess runner + default `brew` lookup (same pairing as ``BrewInstalledPackagesRepository/live()``).
static func live() -> BrewCommandExecutionContext {
BrewCommandExecutionContext(
commandRunner: BrewCommandService(),
locator: BrewExecutableLocator(),
)
}
}
@@ -1,11 +0,0 @@
//
// BrewExecutableLocating.swift
// Brew
//
import Foundation
/// Resolves the `brew` executable — real implementation is [`BrewExecutableLocator`](BrewExecutableLocator.swift); tests may inject fakes (`CONVENTIONS.md` — Testing).
protocol BrewExecutableLocating: Sendable {
func findBrewExecutable() throws -> URL
}
@@ -1,12 +0,0 @@
//
// CommandOutput.swift
// Brew
//
import Foundation
nonisolated struct CommandOutput: Equatable {
var standardOutput: String
var standardError: String
var terminationStatus: Int32
}
@@ -1,11 +0,0 @@
//
// InstalledInventoryEnvironment.swift
// Brew
//
import SwiftUI
extension EnvironmentValues {
/// Inject from ``BrewApp``; otherwise a default cache is created for previews and unscoped subtrees.
@Entry var installedInventoryCache = InstalledInventoryCache()
}
@@ -1,24 +0,0 @@
//
// InstalledInventorySnapshot.swift
// Brew
//
import Foundation
nonisolated struct InstalledInventorySnapshot: Equatable {
nonisolated static let defaultTTL: TimeInterval = 3600
var fetchedAt: Date
let packages: [InstalledBrewPackage]
let graph: PackageDependencyGraph
init(fetchedAt: Date, packages: [InstalledBrewPackage]) {
self.fetchedAt = fetchedAt
self.packages = packages
graph = PackageDependencyGraph(packages: packages)
}
nonisolated func isStale(relativeTo now: Date = .now, ttl: TimeInterval = Self.defaultTTL) -> Bool {
now.timeIntervalSince(fetchedAt) >= ttl
}
}
+1
View File
@@ -3,6 +3,7 @@
// Brew
//
import BrewUIComponents
import SwiftUI
/// Primary navigation items for the main window sidebar.
@@ -1,8 +0,0 @@
@testable import Brew
import Foundation
struct EmptyInstalledDependentsRepository: InstalledDependentsRepository {
func installedDependents(for _: InstalledBrewPackage.ID) async -> [InstalledBrewPackage] {
[]
}
}
@@ -1,8 +0,0 @@
@testable import Brew
import Foundation
struct EmptyInstalledInventoryReading: InstalledInventoryReading {
func installedPackageIDs() async -> Set<InstalledBrewPackage.ID> {
[]
}
}
@@ -1,155 +0,0 @@
//
// InstalledPackagesRepositoryTestSupport.swift
// BrewTests
//
// Shared boundary fakes for `BrewInstalledPackagesRepository` / `InstalledViewModel` slice tests (`CONVENTIONS.md` — Testing).
//
@testable import Brew
import Foundation
// MARK: - Locator fakes
/// Injects `BrewLookupError.executableNotFound` through the real repository (no disk, no real `brew`).
struct MissingBrewExecutableLocator: BrewExecutableLocating {
func findBrewExecutable() throws -> URL {
throw BrewLookupError.executableNotFound
}
}
// MARK: - Command runner
/// Per-invocation result for [`MockBrewCommandRunner`](MockBrewCommandRunner).
enum MockBrewCommandRunnerBehavior {
case output(CommandOutput)
case `throw`(Error)
}
/// Maps `brew` argument lists to output or thrown errors — fails fast on unexpected invocations.
struct MockBrewCommandRunner: BrewCommandRunning {
private let behaviors: [[String]: MockBrewCommandRunnerBehavior]
init(behaviors: [[String]: MockBrewCommandRunnerBehavior]) {
self.behaviors = behaviors
}
/// Convenience: every invocation returns the given `CommandOutput`.
init(responses: [[String]: CommandOutput]) {
behaviors = responses.mapValues { .output($0) }
}
func run(executableURL _: URL, arguments: [String]) async throws -> CommandOutput {
guard let behavior = behaviors[arguments] else {
throw BrewCommandError.failed(exitCode: 99, stderr: "unmocked: \(arguments.joined(separator: " "))")
}
switch behavior {
case let .output(out):
return out
case let .throw(error):
throw error
}
}
}
/// Returns a different `brew info` JSON payload per invocation (last payload repeats), for two-load tests.
actor QueuedBrewInfoRunner: BrewCommandRunning {
private let outputs: [CommandOutput]
private var index = 0
init(infoJSON: [String]) {
outputs = infoJSON.map {
CommandOutput(standardOutput: $0, standardError: "", terminationStatus: 0)
}
}
func run(executableURL _: URL, arguments _: [String]) async throws -> CommandOutput {
let output = outputs[min(index, outputs.count - 1)]
if index < outputs.count - 1 {
index += 1
}
return output
}
}
// MARK: - Fixtures
enum InstalledPackagesTestSupport {
/// Stable fake path passed to `commandRunner` when using `BrewExecutableLocator(overrideURL:)`.
static let fakeBrewExecutableURL = URL(fileURLWithPath: "/fake/brew")
/// Wired like production slice tests: default locator is [`fakeBrewExecutableURL`](fakeBrewExecutableURL).
@MainActor
static func repository(
commandRunner: BrewCommandRunning,
locator: (any BrewExecutableLocating)? = nil,
cache: InstalledInventoryCache? = nil,
commandCenter: any BrewCommandCenter = NoopBrewCommandCenter.forTesting(),
) -> BrewInstalledPackagesRepository {
let resolvedCache = cache ?? InstalledInventoryCache()
let resolvedLocator = locator ?? BrewExecutableLocator(overrideURL: fakeBrewExecutableURL)
return BrewInstalledPackagesRepository(
commandRunner: commandRunner,
locator: resolvedLocator,
cache: resolvedCache,
commandCenter: commandCenter,
)
}
/// Loads (force-refresh) and returns the resulting packages, failing the test if the repository did not reach `.loaded`.
@MainActor
static func loadedPackages(
from repository: BrewInstalledPackagesRepository,
) async -> [InstalledBrewPackage] {
await repository.load(forceRefresh: true)
guard case let .loaded(packages) = repository.state else {
return []
}
return packages
}
// MARK: Localized copy (must match `InstalledViewModel.userMessage`)
static func localizedBrewExecutableNotFoundMessage() -> String {
String(
localized: "Could not find Homebrew. Install it or ensure brew is in the default location.",
comment: "Installed tab error when brew binary missing",
)
}
static func localizedGenericLoadFailureMessage() -> String {
String(
localized: "Something went wrong loading packages.",
comment: "Installed tab generic error",
)
}
/// Response for `brew info --installed --json=v2`.
static func responsesInstalledInfoFailure(
standardOutput: String = "",
standardError: String,
terminationStatus: Int32,
) -> [[String]: CommandOutput] {
[
["info", "--installed", "--json=v2"]: CommandOutput(
standardOutput: standardOutput,
standardError: standardError,
terminationStatus: terminationStatus,
),
]
}
/// Success response for `brew info --installed --json=v2`.
static func installedInfoJSONResponse(
standardOutput: String,
terminationStatus: Int32 = 0,
standardError: String = "",
) -> [[String]: CommandOutput] {
[
["info", "--installed", "--json=v2"]: CommandOutput(
standardOutput: standardOutput,
standardError: standardError,
terminationStatus: terminationStatus,
),
]
}
}
+243
View File
@@ -0,0 +1,243 @@
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "BrewKit",
platforms: [
.macOS("26.0"),
],
products: [
.library(name: "BrewCore", targets: ["BrewCore"]),
.library(name: "BrewUIComponents", targets: ["BrewUIComponents"]),
.library(name: "BrewCLI", targets: ["BrewCLI"]),
.library(name: "BrewNetworking", targets: ["BrewNetworking"]),
.library(name: "BrewRepositoryInterfaces", targets: ["BrewRepositoryInterfaces"]),
.library(name: "BrewRepositories", targets: ["BrewRepositories"]),
.library(name: "BrewAppEnvironment", targets: ["BrewAppEnvironment"]),
.library(name: "BrewCoreTestSupport", targets: ["BrewCoreTestSupport"]),
.library(name: "BrewServicesTestSupport", targets: ["BrewServicesTestSupport"]),
.library(name: "BrewFeatureConsole", targets: ["BrewFeatureConsole"]),
.library(name: "BrewFeatureInstalled", targets: ["BrewFeatureInstalled"]),
.library(name: "BrewFeatureDiscover", targets: ["BrewFeatureDiscover"]),
],
targets: [
.target(
name: "BrewCore",
swiftSettings: [
.defaultIsolation(nil),
.swiftLanguageMode(.v6),
],
),
.target(
name: "BrewUIComponents",
dependencies: ["BrewCore"],
resources: [
.process("Resources/Media.xcassets"),
],
swiftSettings: [
.defaultIsolation(MainActor.self),
.swiftLanguageMode(.v6),
],
),
.target(
name: "BrewCLI",
dependencies: ["BrewCore"],
swiftSettings: [
.defaultIsolation(nil),
.swiftLanguageMode(.v6),
],
),
.target(
name: "BrewNetworking",
dependencies: ["BrewCore"],
swiftSettings: [
.defaultIsolation(nil),
.swiftLanguageMode(.v6),
],
),
.target(
name: "BrewRepositoryInterfaces",
dependencies: ["BrewCore"],
swiftSettings: [
.defaultIsolation(nil),
.swiftLanguageMode(.v6),
],
),
.target(
name: "BrewRepositories",
dependencies: ["BrewRepositoryInterfaces", "BrewCLI", "BrewNetworking"],
swiftSettings: [
.defaultIsolation(nil),
.swiftLanguageMode(.v6),
],
),
.target(
name: "BrewAppEnvironment",
dependencies: ["BrewCore", "BrewRepositoryInterfaces"],
swiftSettings: [
.defaultIsolation(MainActor.self),
.swiftLanguageMode(.v6),
],
),
.target(
name: "BrewCoreTestSupport",
dependencies: ["BrewCore"],
swiftSettings: [
.defaultIsolation(nil),
.swiftLanguageMode(.v6),
],
),
.target(
name: "BrewServicesTestSupport",
dependencies: [
"BrewCore",
"BrewCLI",
"BrewNetworking",
"BrewRepositoryInterfaces",
"BrewRepositories",
"BrewCoreTestSupport",
],
swiftSettings: [
.defaultIsolation(nil),
.swiftLanguageMode(.v6),
],
),
.target(
name: "BrewFeatureConsole",
dependencies: [
"BrewCore",
"BrewUIComponents",
"BrewRepositoryInterfaces",
"BrewAppEnvironment",
],
swiftSettings: [
.defaultIsolation(MainActor.self),
.swiftLanguageMode(.v6),
],
),
.target(
name: "BrewFeatureInstalled",
dependencies: [
"BrewCore",
"BrewUIComponents",
"BrewRepositoryInterfaces",
"BrewAppEnvironment",
],
swiftSettings: [
.defaultIsolation(MainActor.self),
.swiftLanguageMode(.v6),
],
),
.target(
name: "BrewFeatureDiscover",
dependencies: [
"BrewCore",
"BrewUIComponents",
"BrewRepositoryInterfaces",
"BrewAppEnvironment",
],
swiftSettings: [
.defaultIsolation(MainActor.self),
.swiftLanguageMode(.v6),
],
),
// MARK: - Test targets
.testTarget(
name: "BrewCoreTests",
dependencies: ["BrewCore", "BrewCoreTestSupport"],
swiftSettings: [
.defaultIsolation(MainActor.self),
.swiftLanguageMode(.v6),
],
),
.testTarget(
name: "BrewCLITests",
dependencies: ["BrewCLI", "BrewCoreTestSupport", "BrewServicesTestSupport"],
swiftSettings: [
.defaultIsolation(nil),
.swiftLanguageMode(.v6),
],
),
.testTarget(
name: "BrewNetworkingTests",
dependencies: ["BrewNetworking", "BrewCoreTestSupport"],
swiftSettings: [
.defaultIsolation(nil),
.swiftLanguageMode(.v6),
],
),
.testTarget(
name: "BrewRepositoriesTests",
dependencies: [
"BrewRepositories",
"BrewCore",
"BrewCLI",
"BrewNetworking",
"BrewRepositoryInterfaces",
"BrewCoreTestSupport",
"BrewServicesTestSupport",
],
swiftSettings: [
.defaultIsolation(MainActor.self),
.swiftLanguageMode(.v6),
],
),
.testTarget(
name: "BrewUIComponentsTests",
dependencies: ["BrewUIComponents", "BrewCore"],
swiftSettings: [
.defaultIsolation(MainActor.self),
.swiftLanguageMode(.v6),
],
),
.testTarget(
name: "BrewFeatureConsoleTests",
dependencies: [
"BrewFeatureConsole",
"BrewCore",
"BrewRepositoryInterfaces",
"BrewRepositories",
"BrewCoreTestSupport",
"BrewServicesTestSupport",
],
swiftSettings: [
.defaultIsolation(MainActor.self),
.swiftLanguageMode(.v6),
],
),
.testTarget(
name: "BrewFeatureDiscoverTests",
dependencies: [
"BrewFeatureDiscover",
"BrewCLI",
"BrewCore",
"BrewRepositoryInterfaces",
"BrewCoreTestSupport",
"BrewServicesTestSupport",
],
swiftSettings: [
.defaultIsolation(MainActor.self),
.swiftLanguageMode(.v6),
],
),
.testTarget(
name: "BrewFeatureInstalledTests",
dependencies: [
"BrewFeatureInstalled",
"BrewCLI",
"BrewCore",
"BrewUIComponents",
"BrewRepositoryInterfaces",
"BrewRepositories",
"BrewCoreTestSupport",
"BrewServicesTestSupport",
],
swiftSettings: [
.defaultIsolation(MainActor.self),
.swiftLanguageMode(.v6),
],
),
],
)
@@ -0,0 +1,32 @@
//
// RepositoryEnvironment.swift
// BrewAppEnvironment
//
import BrewCore
import BrewRepositoryInterfaces
import SwiftUI
public extension EnvironmentValues {
/// App-scoped installed-inventory source of truth, injected at the nearest common ancestor of the
/// installed-aware features. The default traps — the composition root must inject a live instance.
@Entry var installedPackagesRepository: any InstalledPackagesRepository = UnimplementedInstalledPackagesRepository()
/// Discover top-packages source, injected by the composition root.
@Entry var discoverPackagesRepository: any DiscoverPackagesRepository = UnimplementedDiscoverPackagesRepository()
/// Catalogue lookups/search, injected by the composition root.
@Entry var catalogueRepository: any CatalogueRepository = UnimplementedCatalogueRepository()
/// Live projection of command-center operations for the console, injected by the composition root.
@Entry var commandJobsRepository: any CommandJobsObserving = UnimplementedCommandJobsObserving()
/// Reverse-dependency lookups over the installed inventory, injected by the composition root.
@Entry var installedDependentsRepository: any InstalledDependentsRepository = UnimplementedDependentsRepository()
/// Mutating `brew` coordinator, injected by the composition root.
@Entry var brewCommandCenter: any BrewCommandCenter = UnimplementedBrewCommandCenter()
/// Builds mutating `brew` commands for view models, injected by the composition root.
@Entry var mutatingCommandFactory: any BrewMutatingCommandFactory = UnimplementedMutatingCommandFactory()
}
@@ -0,0 +1,127 @@
//
// UnimplementedRepositories.swift
// BrewAppEnvironment
//
import BrewCore
import BrewRepositoryInterfaces
import Foundation
import Observation
private nonisolated func unimplemented(_ function: StaticString = #function) -> Never {
fatalError("\(function): unimplemented — inject a repository at the composition root.")
}
@Observable
@MainActor
final class UnimplementedInstalledPackagesRepository: InstalledPackagesRepository {
var state: LoadState<[InstalledBrewPackage], any Error> {
unimplemented()
}
func load(forceRefresh _: Bool) async {
unimplemented()
}
func isInstalled(_: HomebrewPackageID) -> Bool {
unimplemented()
}
func info(for _: HomebrewPackageID) -> InstalledBrewPackage? {
unimplemented()
}
func installedPackageIDs() async -> Set<InstalledBrewPackage.ID> {
unimplemented()
}
}
struct UnimplementedDiscoverPackagesRepository: DiscoverPackagesRepository {
func loadTopPackages(limit _: Int, window _: BrewAnalyticsWindow) async throws -> DiscoverTopPackagesSnapshot {
unimplemented()
}
}
struct UnimplementedCatalogueRepository: CatalogueRepository {
func package(for _: HomebrewPackageID) async throws -> BrewPackage? {
unimplemented()
}
func searchPackages(matching _: String, limit _: Int) async throws -> [BrewPackage] {
unimplemented()
}
}
struct UnimplementedDependentsRepository: InstalledDependentsRepository {
func installedDependents(for _: InstalledBrewPackage.ID) async -> [InstalledBrewPackage] {
unimplemented()
}
}
struct UnimplementedMutatingCommandFactory: BrewMutatingCommandFactory {
func installCommand(kind _: HomebrewPackageKind, name _: String) -> any BrewMutatingCommand {
unimplemented()
}
func upgradeCommand(kind _: HomebrewPackageKind, name _: String) -> any BrewMutatingCommand {
unimplemented()
}
func uninstallCommand(kind _: HomebrewPackageKind, name _: String) -> any BrewMutatingCommand {
unimplemented()
}
}
@Observable
@MainActor
final class UnimplementedCommandJobsObserving: CommandJobsObserving {
var jobs: [BrewOperationID: CommandJob] {
unimplemented()
}
var orderedIDs: [BrewOperationID] {
unimplemented()
}
func remove(id _: BrewOperationID) {
unimplemented()
}
func clearCompleted() {
unimplemented()
}
}
actor UnimplementedBrewCommandCenter: BrewCommandCenter {
func phase(for _: BrewOperationID) async -> BrewOperationPhase {
unimplemented()
}
func phaseByID() async -> [BrewOperationID: BrewOperationPhase] {
unimplemented()
}
func isActive(id _: BrewOperationID) async -> Bool {
unimplemented()
}
func phaseChanges(for _: BrewOperationID) async -> AsyncStream<BrewOperationPhase> {
unimplemented()
}
func allPhaseChanges() async -> AsyncStream<(BrewOperationID, BrewOperationPhase)> {
unimplemented()
}
func outputChanges(for _: BrewOperationID) async -> AsyncStream<BrewCommandOutputLine> {
unimplemented()
}
func allOutputChanges() async -> AsyncStream<(BrewOperationID, BrewCommandOutputLine)> {
unimplemented()
}
func submit(id _: BrewOperationID, command _: any BrewMutatingCommand) async throws {
unimplemented()
}
}
@@ -0,0 +1,17 @@
//
// BrewCommandExecutionContext+Live.swift
// BrewCLI
//
import BrewCore
import Foundation
public extension BrewCommandExecutionContext {
/// Production wiring: real subprocess runner + default `brew` lookup.
static func live() -> BrewCommandExecutionContext {
BrewCommandExecutionContext(
commandRunner: BrewCommandService(),
locator: BrewExecutableLocator(),
)
}
}
@@ -3,11 +3,12 @@
// Brew
//
import BrewCore
import Foundation
extension BrewCommandExecutionContext {
public extension BrewCommandExecutionContext {
/// Runner returns empty success without touching disk; locator yields a dummy `brew` URL for callers that resolve it.
nonisolated static func noopForTestingAndPreviews() -> BrewCommandExecutionContext {
static func noopForTestingAndPreviews() -> BrewCommandExecutionContext {
BrewCommandExecutionContext(
commandRunner: ImmediateSuccessCommandRunner(),
locator: BrewExecutableLocator(overrideURL: URL(fileURLWithPath: "/opt/homebrew/bin/brew")),
@@ -15,7 +16,7 @@ extension BrewCommandExecutionContext {
}
}
private nonisolated struct ImmediateSuccessCommandRunner: BrewCommandRunning {
private struct ImmediateSuccessCommandRunner: BrewCommandRunning {
func run(executableURL _: URL, arguments _: [String]) async throws -> CommandOutput {
CommandOutput(standardOutput: "", standardError: "", terminationStatus: 0)
}
@@ -3,13 +3,14 @@
// Brew
//
import BrewCore
import Foundation
/// Subprocess runner for Homebrew CLI (`ARCHITECTURE.md` — Command execution).
struct BrewCommandService: BrewCommandRunning {
nonisolated init() {}
public struct BrewCommandService: BrewCommandRunning {
public init() {}
nonisolated func run(executableURL: URL, arguments: [String]) async throws -> CommandOutput {
public func run(executableURL: URL, arguments: [String]) async throws -> CommandOutput {
try Task.checkCancellation()
let process = Process()
@@ -68,7 +69,7 @@ struct BrewCommandService: BrewCommandRunning {
/// When `sink` is non-nil, also emits each `\n`-terminated line through it as bytes arrive — the trailing
/// partial line (no terminating newline) is flushed on EOF if non-empty. Lines whose bytes aren't valid UTF-8
/// are dropped from the stream (they remain in the verbatim byte return).
private nonisolated static func drainPipe(
private static func drainPipe(
handle: FileHandle,
stream: BrewCommandOutputLine.Stream,
sink: (@Sendable (BrewCommandOutputLine) -> Void)?,
@@ -106,7 +107,7 @@ struct BrewCommandService: BrewCommandRunning {
}
}
private nonisolated static func waitForExit(_ process: Process) async -> Int32 {
private static func waitForExit(_ process: Process) async -> Int32 {
await withCheckedContinuation { continuation in
// `waitUntilExit` blocks; run on a GCD worker thread.
DispatchQueue.global(qos: .utility).async {
@@ -117,15 +118,18 @@ struct BrewCommandService: BrewCommandRunning {
}
}
// `process` is the only non-Sendable stored state, and every access to it goes through `lock`; the
// sole mutating call (`terminate`) runs under that lock, so concurrent use is serialised.
// swiftlint:disable:next unchecked_sendable
private final class ProcessController: @unchecked Sendable {
private let lock = NSLock()
private let process: Process
nonisolated init(_ process: Process) {
init(_ process: Process) {
self.process = process
}
nonisolated func terminateIfRunning() {
func terminateIfRunning() {
lock.lock()
defer { lock.unlock() }
if process.isRunning {
@@ -3,15 +3,16 @@
// Brew
//
import BrewCore
import Foundation
/// Resolves the `brew` binary without relying on a single hard-coded prefix (`AGENTS.md`).
struct BrewExecutableLocator: BrewExecutableLocating {
public struct BrewExecutableLocator: BrewExecutableLocating {
private let isExecutableAtPath: @Sendable (String) -> Bool
/// When set (tests only), skip filesystem probing.
private let overrideURL: URL?
nonisolated init(
public init(
isExecutableAtPath: @escaping @Sendable (String) -> Bool = { path in
FileManager.default.isExecutableFile(atPath: path)
},
@@ -20,7 +21,7 @@ struct BrewExecutableLocator: BrewExecutableLocating {
overrideURL = nil
}
nonisolated init(overrideURL: URL) {
public init(overrideURL: URL) {
isExecutableAtPath = { path in
FileManager.default.isExecutableFile(atPath: path)
}
@@ -28,7 +29,7 @@ struct BrewExecutableLocator: BrewExecutableLocating {
}
/// Tries Apple Silicon default, then Intel default.
nonisolated func findBrewExecutable() throws -> URL {
public func findBrewExecutable() throws -> URL {
if let overrideURL {
return overrideURL
}
@@ -3,11 +3,12 @@
// Brew
//
import BrewCore
import Foundation
/// Shared installed-inventory storage with TTL semantics. Domain reads (packages, dependents) go through repositories, not this actor.
actor InstalledInventoryCache {
enum CacheResult {
public actor InstalledInventoryCache {
public enum CacheResult: Sendable {
case fresh([InstalledBrewPackage])
case stale([InstalledBrewPackage])
case empty
@@ -15,15 +16,17 @@ actor InstalledInventoryCache {
private var snapshot: InstalledInventorySnapshot?
func replace(_ snapshot: InstalledInventorySnapshot) {
public init() {}
public func replace(_ snapshot: InstalledInventorySnapshot) {
self.snapshot = snapshot
}
func currentSnapshot() -> InstalledInventorySnapshot? {
public func currentSnapshot() -> InstalledInventorySnapshot? {
snapshot
}
func cachedPackages() -> CacheResult {
public func cachedPackages() -> CacheResult {
guard let snapshot else { return .empty }
return snapshot.isStale() ? .stale(snapshot.packages) : .fresh(snapshot.packages)
}
@@ -0,0 +1,25 @@
//
// InstalledInventorySnapshot.swift
// Brew
//
import BrewCore
import Foundation
public struct InstalledInventorySnapshot: Equatable, Sendable {
public static let defaultTTL: TimeInterval = 3600
public var fetchedAt: Date
public let packages: [InstalledBrewPackage]
public let graph: PackageDependencyGraph
public init(fetchedAt: Date, packages: [InstalledBrewPackage]) {
self.fetchedAt = fetchedAt
self.packages = packages
graph = PackageDependencyGraph(packages: packages)
}
public func isStale(relativeTo now: Date = .now, ttl: TimeInterval = Self.defaultTTL) -> Bool {
now.timeIntervalSince(fetchedAt) >= ttl
}
}
@@ -3,16 +3,17 @@
// Brew
//
import BrewCore
import Foundation
extension BrewInfoJSON {
public extension BrewInfoJSON {
func installedPackages() -> [InstalledBrewPackage] {
let formulaPackages = formulae.map(\.asBrewPackage)
let caskPackages = casks.map(\.asBrewPackage)
return (formulaPackages + caskPackages).sorted(by: Self.sortByName)
}
private nonisolated static func sortByName(_ lhs: InstalledBrewPackage, _ rhs: InstalledBrewPackage) -> Bool {
private static func sortByName(_ lhs: InstalledBrewPackage, _ rhs: InstalledBrewPackage) -> Bool {
lhs.package.name.localizedCaseInsensitiveCompare(rhs.package.name) == .orderedAscending
}
}
@@ -59,7 +60,7 @@ private extension BrewInfoCask {
}
private extension BrewInfoJSON {
nonisolated static func uniqueNonEmpty(_ values: [String]) -> [String] {
static func uniqueNonEmpty(_ values: [String]) -> [String] {
var seen: Set<String> = []
var result: [String] = []
for value in values {
@@ -72,14 +73,14 @@ private extension BrewInfoJSON {
return result
}
nonisolated static func trimmedOrNil(_ value: String?) -> String? {
static func trimmedOrNil(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
return nil
}
return trimmed
}
nonisolated static func trimmedOrEmpty(_ value: String?) -> String {
static func trimmedOrEmpty(_ value: String?) -> String {
trimmedOrNil(value) ?? ""
}
}
@@ -3,14 +3,15 @@
// Brew
//
import BrewCore
import Foundation
/// Minimal resilient schema for `brew info --json=v2`.
struct BrewInfoJSON: Decodable {
public struct BrewInfoJSON: Decodable {
var formulae: [BrewInfoFormula]
var casks: [BrewInfoCask]
init(from decoder: Decoder) throws {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
formulae = (try? container.decode([BrewInfoFormula].self, forKey: .formulae)) ?? []
casks = (try? container.decode([BrewInfoCask].self, forKey: .casks)) ?? []
@@ -0,0 +1,24 @@
//
// LiveBrewMutatingCommandFactory.swift
// BrewCLI
//
import BrewCore
import Foundation
/// Production factory vending the concrete `brew` command types from this module.
public struct LiveBrewMutatingCommandFactory: BrewMutatingCommandFactory {
public init() {}
public func installCommand(kind: HomebrewPackageKind, name: String) -> any BrewMutatingCommand {
PackageInstallCommand(kind: kind, name: name)
}
public func upgradeCommand(kind: HomebrewPackageKind, name: String) -> any BrewMutatingCommand {
PackageUpgradeCommand(kind: kind, name: name)
}
public func uninstallCommand(kind: HomebrewPackageKind, name: String) -> any BrewMutatingCommand {
PackageUninstallCommand(kind: kind, name: name)
}
}
@@ -1,43 +1,44 @@
//
// NoopBrewCommandCenter.swift
// Brew
// BrewCLI
//
import BrewCore
import Foundation
/// Pass-through center for previews and tests: runs commands immediately with no phase bookkeeping or queuing.
actor NoopBrewCommandCenter: BrewCommandCenter {
public actor NoopBrewCommandCenter: BrewCommandCenter {
private let executionContext: BrewCommandExecutionContext
init(executionContext: BrewCommandExecutionContext) {
public init(executionContext: BrewCommandExecutionContext) {
self.executionContext = executionContext
}
/// Preconfigured noop center for SwiftUI previews (immediate execution, standard noop context).
nonisolated static func preview() -> NoopBrewCommandCenter {
public static func preview() -> NoopBrewCommandCenter {
NoopBrewCommandCenter(executionContext: .noopForTestingAndPreviews())
}
/// Preconfigured noop center for unit tests (same wiring as ``preview()``).
nonisolated static func forTesting() -> NoopBrewCommandCenter {
public static func forTesting() -> NoopBrewCommandCenter {
NoopBrewCommandCenter(executionContext: .noopForTestingAndPreviews())
}
func phase(for id: BrewOperationID) async -> BrewOperationPhase {
public func phase(for id: BrewOperationID) async -> BrewOperationPhase {
_ = id
return .idle
}
func phaseByID() async -> [BrewOperationID: BrewOperationPhase] {
public func phaseByID() async -> [BrewOperationID: BrewOperationPhase] {
[:]
}
func isActive(id: BrewOperationID) async -> Bool {
public func isActive(id: BrewOperationID) async -> Bool {
_ = id
return false
}
func phaseChanges(for id: BrewOperationID) async -> AsyncStream<BrewOperationPhase> {
public func phaseChanges(for id: BrewOperationID) async -> AsyncStream<BrewOperationPhase> {
_ = id
return AsyncStream<BrewOperationPhase>(bufferingPolicy: .unbounded) { continuation in
continuation.yield(BrewOperationPhase.idle)
@@ -45,26 +46,26 @@ actor NoopBrewCommandCenter: BrewCommandCenter {
}
}
func allPhaseChanges() async -> AsyncStream<(BrewOperationID, BrewOperationPhase)> {
public func allPhaseChanges() async -> AsyncStream<(BrewOperationID, BrewOperationPhase)> {
AsyncStream<(BrewOperationID, BrewOperationPhase)>(bufferingPolicy: .unbounded) { continuation in
continuation.finish()
}
}
func outputChanges(for id: BrewOperationID) async -> AsyncStream<BrewCommandOutputLine> {
public func outputChanges(for id: BrewOperationID) async -> AsyncStream<BrewCommandOutputLine> {
_ = id
return AsyncStream<BrewCommandOutputLine>(bufferingPolicy: .unbounded) { continuation in
continuation.finish()
}
}
func allOutputChanges() async -> AsyncStream<(BrewOperationID, BrewCommandOutputLine)> {
public func allOutputChanges() async -> AsyncStream<(BrewOperationID, BrewCommandOutputLine)> {
AsyncStream<(BrewOperationID, BrewCommandOutputLine)>(bufferingPolicy: .unbounded) { continuation in
continuation.finish()
}
}
func submit(
public func submit(
id: BrewOperationID,
command: any BrewMutatingCommand,
) async throws {
@@ -3,12 +3,13 @@
// Brew
//
import BrewCore
import Foundation
/// Schedules `brew install <name>` or `brew install --cask <name>` via ``BrewCommandCenter/submit``,
/// using ``BrewCommandExecutionContext`` for subprocess execution and `brew` resolution.
///
nonisolated struct PackageInstallCommand: BrewMutatingCommand {
struct PackageInstallCommand: BrewMutatingCommand {
let packageName: String
let kind: InstalledPackageKind
@@ -22,7 +23,7 @@ nonisolated struct PackageInstallCommand: BrewMutatingCommand {
self.kind = kind
}
nonisolated var operationKind: BrewOperationKind {
var operationKind: BrewOperationKind {
switch kind {
case .formula:
.installFormula
@@ -2,6 +2,7 @@
// PackageUninstallCommand.swift
// Brew
//
import BrewCore
import Foundation
/// Schedules `brew uninstall <name>` or `brew uninstall --cask <name>` via ``BrewCommandCenter/submit``,
@@ -20,7 +21,7 @@ struct PackageUninstallCommand: BrewMutatingCommand {
self.kind = kind
}
nonisolated var operationKind: BrewOperationKind {
var operationKind: BrewOperationKind {
switch kind {
case .formula:
.uninstallFormula
@@ -3,6 +3,7 @@
// Brew
//
import BrewCore
import Foundation
/// Schedules `brew upgrade <name>` or `brew upgrade --cask <name>` via ``BrewCommandCenter/submit``,
@@ -22,7 +23,7 @@ struct PackageUpgradeCommand: BrewMutatingCommand {
self.kind = kind
}
nonisolated var operationKind: BrewOperationKind {
var operationKind: BrewOperationKind {
switch kind {
case .formula:
.upgradeFormula
@@ -1,8 +1,9 @@
//
// SerialBrewCommandCenter.swift
// Brew
// BrewCLI
//
import BrewCore
import Foundation
private typealias AllPhaseStreamTermination =
@@ -42,7 +43,7 @@ private struct AllOutputStreamListener {
}
/// Default app implementation: serializes mutating `brew` subprocess work and exposes per-operation phase for UI.
actor SerialBrewCommandCenter: BrewCommandCenter {
public actor SerialBrewCommandCenter: BrewCommandCenter {
private let executionContext: BrewCommandExecutionContext
private let workQueue = SerialBrewWorkQueue()
@@ -53,26 +54,26 @@ actor SerialBrewCommandCenter: BrewCommandCenter {
private var outputListenersByID: [BrewOperationID: [OutputStreamListener]] = [:]
private var allOutputListeners: [AllOutputStreamListener] = []
init(executionContext: BrewCommandExecutionContext) {
public init(executionContext: BrewCommandExecutionContext) {
self.executionContext = executionContext
}
func phase(for id: BrewOperationID) async -> BrewOperationPhase {
public func phase(for id: BrewOperationID) async -> BrewOperationPhase {
trackedPhasesByID[id] ?? .idle
}
func phaseByID() async -> [BrewOperationID: BrewOperationPhase] {
public func phaseByID() async -> [BrewOperationID: BrewOperationPhase] {
trackedPhasesByID
}
func isActive(id: BrewOperationID) async -> Bool {
public func isActive(id: BrewOperationID) async -> Bool {
if case .running = trackedPhasesByID[id] ?? .idle {
return true
}
return false
}
func phaseChanges(for id: BrewOperationID) async -> AsyncStream<BrewOperationPhase> {
public func phaseChanges(for id: BrewOperationID) async -> AsyncStream<BrewOperationPhase> {
AsyncStream<BrewOperationPhase>(bufferingPolicy: .unbounded) { continuation in
let token = UUID()
continuation.onTermination = { @Sendable (_: AsyncStream<BrewOperationPhase>.Continuation.Termination) in
@@ -84,7 +85,7 @@ actor SerialBrewCommandCenter: BrewCommandCenter {
}
}
func allPhaseChanges() async -> AsyncStream<(BrewOperationID, BrewOperationPhase)> {
public func allPhaseChanges() async -> AsyncStream<(BrewOperationID, BrewOperationPhase)> {
AsyncStream<(BrewOperationID, BrewOperationPhase)>(bufferingPolicy: .unbounded) { continuation in
let token = UUID()
continuation.onTermination = { @Sendable (_: AllPhaseStreamTermination) in
@@ -96,7 +97,7 @@ actor SerialBrewCommandCenter: BrewCommandCenter {
}
}
func outputChanges(for id: BrewOperationID) async -> AsyncStream<BrewCommandOutputLine> {
public func outputChanges(for id: BrewOperationID) async -> AsyncStream<BrewCommandOutputLine> {
AsyncStream<BrewCommandOutputLine>(bufferingPolicy: .unbounded) { continuation in
let token = UUID()
continuation.onTermination = { @Sendable (_: OutputStreamTermination) in
@@ -108,7 +109,7 @@ actor SerialBrewCommandCenter: BrewCommandCenter {
}
}
func allOutputChanges() async -> AsyncStream<(BrewOperationID, BrewCommandOutputLine)> {
public func allOutputChanges() async -> AsyncStream<(BrewOperationID, BrewCommandOutputLine)> {
AsyncStream<(BrewOperationID, BrewCommandOutputLine)>(bufferingPolicy: .unbounded) { continuation in
let token = UUID()
continuation.onTermination = { @Sendable (_: AllOutputStreamTermination) in
@@ -120,7 +121,7 @@ actor SerialBrewCommandCenter: BrewCommandCenter {
}
}
func submit(
public func submit(
id: BrewOperationID,
command: any BrewMutatingCommand,
) async throws {
@@ -1,31 +1,30 @@
//
// BrewAnalyticsJSON.swift
// Brew
// BrewCore
//
import Foundation
/// Supported Homebrew analytics windows used by Discover.
nonisolated enum BrewAnalyticsWindow: String, CaseIterable {
public enum BrewAnalyticsWindow: String, CaseIterable, Sendable {
case days30 = "30d"
case days90 = "90d"
case days365 = "365d"
}
/// Strict schema for Homebrew analytics API responses.
nonisolated struct BrewAnalyticsJSON: Decodable {
let category: String
let totalItems: Int
let totalCount: Int
let startDate: String
let endDate: String
public struct BrewAnalyticsJSON: Decodable, Sendable {
public let category: String
public let totalItems: Int
public let totalCount: Int
public let startDate: String
public let endDate: String
private let parsedPackageCounts: [BrewAnalyticsPackageCount]
var packageCounts: [BrewAnalyticsPackageCount] {
public var packageCounts: [BrewAnalyticsPackageCount] {
parsedPackageCounts
}
init(from decoder: Decoder) throws {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
category = try container.decodeNonEmptyString(forKey: .category)
totalItems = try container.decodeRequiredIntLossy(forKey: .totalItems)
@@ -74,16 +73,21 @@ nonisolated struct BrewAnalyticsJSON: Decodable {
}
}
nonisolated struct BrewAnalyticsPackageCount: Hashable {
let reference: HomebrewPackageID
let count: Int
public struct BrewAnalyticsPackageCount: Hashable, Sendable {
public let reference: HomebrewPackageID
public let count: Int
var name: String {
public var name: String {
reference.name
}
public init(reference: HomebrewPackageID, count: Int) {
self.reference = reference
self.count = count
}
}
private nonisolated struct BrewAnalyticsEntry: Decodable {
private struct BrewAnalyticsEntry: Decodable {
let formula: String?
let cask: String?
let count: Int
@@ -136,7 +140,7 @@ private nonisolated struct BrewAnalyticsEntry: Decodable {
}
private extension KeyedDecodingContainer {
nonisolated func decodeRequiredIntLossy(forKey key: Key) throws -> Int {
func decodeRequiredIntLossy(forKey key: Key) throws -> Int {
if let value = try? decode(Int.self, forKey: key) {
return value
}
@@ -153,7 +157,7 @@ private extension KeyedDecodingContainer {
)
}
nonisolated func decodeNonEmptyString(forKey key: Key) throws -> String {
func decodeNonEmptyString(forKey key: Key) throws -> String {
let value = try decode(String.self, forKey: key)
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
+42
View File
@@ -0,0 +1,42 @@
//
// BrewPackage.swift
// BrewCore
//
import Foundation
public struct BrewPackage: Identifiable, Hashable, Sendable {
public let name: String
public let displayName: String
public let kind: HomebrewPackageKind
public var description: String
public var homepage: String
public var latestVersion: String
public var dependencies: [HomebrewPackageID]
public var id: HomebrewPackageID {
reference
}
public var reference: HomebrewPackageID {
HomebrewPackageID(package: self)
}
public init(
name: String,
displayName: String,
kind: HomebrewPackageKind,
description: String,
homepage: String,
latestVersion: String,
dependencies: [HomebrewPackageID],
) {
self.name = name
self.displayName = displayName
self.kind = kind
self.description = description
self.homepage = homepage
self.latestVersion = latestVersion
self.dependencies = dependencies
}
}
@@ -1,12 +1,12 @@
//
// DiscoveryBrewPackage+Placeholdable.swift
// Brew
// BrewCore
//
import Foundation
nonisolated extension DiscoveryBrewPackage: Placeholdable {
static var placeholder: DiscoveryBrewPackage {
extension DiscoveryBrewPackage: Placeholdable {
public static var placeholder: DiscoveryBrewPackage {
DiscoveryBrewPackage(
package: BrewPackage(
name: "placeholder-package",
@@ -22,10 +22,10 @@ nonisolated extension DiscoveryBrewPackage: Placeholdable {
}
}
nonisolated extension [DiscoveryBrewPackage]: Placeholdable {
extension [DiscoveryBrewPackage]: Placeholdable {
/// A spread of distinct, mixed-kind stub packages. Distinct ids keep `ForEach` happy, and the
/// formula/cask mix lets both sections render redacted under the "All" scope while loading.
static var placeholder: [DiscoveryBrewPackage] {
public static var placeholder: [DiscoveryBrewPackage] {
(0 ..< 8).map { index in
let kind: HomebrewPackageKind = index.isMultiple(of: 2) ? .formula : .cask
return DiscoveryBrewPackage(
@@ -0,0 +1,67 @@
//
// DiscoveryBrewPackage.swift
// BrewCore
//
import Foundation
/// Domain output for Discover top package sections.
public struct DiscoverTopPackagesSnapshot: Equatable, Sendable {
public let topFormulae: [DiscoveryBrewPackage]
public let topCasks: [DiscoveryBrewPackage]
public init(topFormulae: [DiscoveryBrewPackage], topCasks: [DiscoveryBrewPackage]) {
self.topFormulae = topFormulae
self.topCasks = topCasks
}
}
public struct DiscoveryBrewPackage: Identifiable, Equatable, Hashable, Sendable {
public var package: BrewPackage
public var thirtyDayInstallCount: Int
public var name: String {
package.name
}
public var displayName: String {
package.displayName
}
public var kind: HomebrewPackageKind {
package.kind
}
public var description: String {
get { package.description }
set { package.description = newValue }
}
public var homepage: String {
get { package.homepage }
set { package.homepage = newValue }
}
public var latestVersion: String {
get { package.latestVersion }
set { package.latestVersion = newValue }
}
public var dependencies: [HomebrewPackageID] {
get { package.dependencies }
set { package.dependencies = newValue }
}
public var id: HomebrewPackageID {
package.id
}
public var reference: HomebrewPackageID {
HomebrewPackageID(package: package)
}
public init(package: BrewPackage, thirtyDayInstallCount: Int) {
self.package = package
self.thirtyDayInstallCount = thirtyDayInstallCount
}
}
@@ -1,6 +1,6 @@
//
// HomebrewPackageID.swift
// Brew
// BrewCore
//
import Foundation
@@ -9,15 +9,15 @@ import Foundation
///
/// This is the canonical package identity across the app: every package type's
/// `id` is a `HomebrewPackageID`, and the type is its own `Identifiable.ID`.
nonisolated enum HomebrewPackageID: Hashable, Identifiable {
public enum HomebrewPackageID: Hashable, Identifiable, Sendable {
case formula(name: String)
case cask(token: String)
var id: Self {
public var id: Self {
self
}
var kind: HomebrewPackageKind {
public var kind: HomebrewPackageKind {
switch self {
case .formula:
.formula
@@ -26,7 +26,7 @@ nonisolated enum HomebrewPackageID: Hashable, Identifiable {
}
}
var name: String {
public var name: String {
switch self {
case let .formula(name):
name
@@ -35,7 +35,7 @@ nonisolated enum HomebrewPackageID: Hashable, Identifiable {
}
}
init(package: BrewPackage) {
public init(package: BrewPackage) {
switch package.kind {
case .formula:
self = .formula(name: package.name)
@@ -44,12 +44,12 @@ nonisolated enum HomebrewPackageID: Hashable, Identifiable {
}
}
init(installedPackage: InstalledBrewPackage) {
public init(installedPackage: InstalledBrewPackage) {
self.init(package: installedPackage.package)
}
}
nonisolated extension HomebrewPackageID {
public extension HomebrewPackageID {
static func formulaDependencies(from names: [String]) -> [HomebrewPackageID] {
uniqueReferences(names.map { .formula(name: $0) })
}
@@ -1,15 +1,15 @@
//
// HomebrewPackageKind.swift
// Brew
// BrewCore
//
import Foundation
/// Whether an installed Homebrew unit is a formula or a cask — domain-level discriminator (repositories, operations, presentation).
nonisolated enum HomebrewPackageKind: String, Hashable {
public enum HomebrewPackageKind: String, Hashable, Sendable {
case formula
case cask
}
/// Stable synonym used across the Installed feature and tests — identical to ``HomebrewPackageKind``.
typealias InstalledPackageKind = HomebrewPackageKind
public typealias InstalledPackageKind = HomebrewPackageKind
@@ -0,0 +1,58 @@
//
// InstalledBrewPackage.swift
// BrewCore
//
import Foundation
public struct InstalledBrewPackage: Identifiable, Hashable, Sendable {
public var package: BrewPackage
public var installedVersions: [String]
public var outdated: Bool
public var name: String {
package.name
}
public var displayName: String {
package.displayName
}
public var kind: HomebrewPackageKind {
package.kind
}
public var description: String {
get { package.description }
set { package.description = newValue }
}
public var homepage: String {
get { package.homepage }
set { package.homepage = newValue }
}
public var latestVersion: String {
get { package.latestVersion }
set { package.latestVersion = newValue }
}
public var dependencies: [HomebrewPackageID] {
get { package.dependencies }
set { package.dependencies = newValue }
}
public var id: HomebrewPackageID {
package.id
}
public var reference: HomebrewPackageID {
HomebrewPackageID(package: package)
}
public init(package: BrewPackage, installedVersions: [String], outdated: Bool) {
self.package = package
self.installedVersions = installedVersions
self.outdated = outdated
}
}
@@ -1,16 +1,16 @@
//
// PackageDependencyGraph.swift
// Brew
// BrewCore
//
import Foundation
/// Reverse dependency lookups over one installed-inventory snapshot.
nonisolated struct PackageDependencyGraph: Equatable {
public struct PackageDependencyGraph: Equatable, Sendable {
private let packagesByID: [InstalledBrewPackage.ID: InstalledBrewPackage]
private let dependentsByDependencyPackageID: [InstalledBrewPackage.ID: [InstalledBrewPackage.ID]]
init(packages: [InstalledBrewPackage]) {
public init(packages: [InstalledBrewPackage]) {
var byID: [InstalledBrewPackage.ID: InstalledBrewPackage] = [:]
var reverse: [InstalledBrewPackage.ID: [InstalledBrewPackage.ID]] = [:]
@@ -25,7 +25,7 @@ nonisolated struct PackageDependencyGraph: Equatable {
dependentsByDependencyPackageID = reverse
}
func installedDependents(for packageID: InstalledBrewPackage.ID) -> [InstalledBrewPackage] {
public func installedDependents(for packageID: InstalledBrewPackage.ID) -> [InstalledBrewPackage] {
(dependentsByDependencyPackageID[packageID] ?? [])
.compactMap { packagesByID[$0] }
.sorted {
@@ -0,0 +1,16 @@
//
// BrewAPIClientError.swift
// BrewCore
//
import Foundation
/// Failure surfaced by the Homebrew JSON API client. Lives in BrewCore so feature view models can map it
/// to user-facing copy without importing the networking layer (mirrors ``BrewCommandError``).
public enum BrewAPIClientError: Error, Equatable, Sendable {
case invalidURL(path: String)
case transport(underlying: String)
case invalidResponse
case httpStatus(code: Int, bodySnippet: String)
case decoding(underlying: String)
}
@@ -1,12 +1,12 @@
//
// BrewCommandCenter.swift
// Brew
// BrewCore
//
import Foundation
/// Coordinates mutating `brew` operations and cross-surface visibility (`ARCHITECTURE.md` — command execution).
protocol BrewCommandCenter: Actor {
public protocol BrewCommandCenter: Actor {
/// Snapshot for UI — ``BrewOperationPhase/idle`` when no state is tracked for `id`.
func phase(for id: BrewOperationID) async -> BrewOperationPhase
@@ -1,12 +1,12 @@
//
// BrewCommandError.swift
// Brew
// BrewCore
//
import Foundation
/// `brew` exited non-zero or could not be launched.
nonisolated enum BrewCommandError: Error, Equatable {
public enum BrewCommandError: Error, Equatable, Sendable {
case failed(exitCode: Int32, stderr: String)
case launchFailed(underlying: String)
}
@@ -0,0 +1,21 @@
//
// BrewCommandExecutionContext.swift
// BrewCore
//
import Foundation
/// Dependencies for mutating `brew` subprocess work passed into ``BrewMutatingCommand/run(in:)``.
public struct BrewCommandExecutionContext: Sendable {
public var commandRunner: BrewCommandRunning
public var locator: BrewExecutableLocating
public init(commandRunner: BrewCommandRunning, locator: BrewExecutableLocating) {
self.commandRunner = commandRunner
self.locator = locator
}
public func brewExecutableURL() throws -> URL {
try locator.findBrewExecutable()
}
}
@@ -1,6 +1,6 @@
//
// BrewCommandOutputContext.swift
// Brew
// BrewCore
//
import Foundation
@@ -8,10 +8,10 @@ import Foundation
/// Task-local plumbing for streaming subprocess output without threading a sink through every command type.
///
/// ``BrewCommandCenter/submit(id:command:)`` implementations set ``sink`` for the duration of a submit;
/// ``BrewCommandService`` reads the value and forwards each `\n`-delimited line through it as the subprocess emits bytes.
/// `BrewCommandService` reads the value and forwards each `\n`-delimited line through it as the subprocess emits bytes.
/// Commands themselves remain unchanged — they still call `context.commandRunner.run(...)`.
nonisolated enum BrewCommandOutputContext {
public enum BrewCommandOutputContext {
/// Per-task sink for subprocess output. `nil` when no console is observing — the runner falls back to the
/// existing buffered-read path with no overhead.
@TaskLocal static var sink: (@Sendable (BrewCommandOutputLine) -> Void)?
@TaskLocal public static var sink: (@Sendable (BrewCommandOutputLine) -> Void)?
}
@@ -1,23 +1,23 @@
//
// BrewCommandOutputLine.swift
// Brew
// BrewCore
//
import Foundation
/// One line of subprocess output, attributed to a stream (`ARCHITECTURE.md` — command execution; transparency).
nonisolated struct BrewCommandOutputLine: Identifiable, Equatable {
let id: UUID
let stream: Stream
let text: String
let timestamp: Date
public struct BrewCommandOutputLine: Identifiable, Equatable, Sendable {
public let id: UUID
public let stream: Stream
public let text: String
public let timestamp: Date
enum Stream: Equatable {
public enum Stream: Equatable, Sendable {
case stdout
case stderr
}
init(stream: Stream, text: String, timestamp: Date = Date(), id: UUID = UUID()) {
public init(stream: Stream, text: String, timestamp: Date = Date(), id: UUID = UUID()) {
self.id = id
self.stream = stream
self.text = text
@@ -1,11 +1,11 @@
//
// BrewCommandRunning.swift
// Brew
// BrewCore
//
import Foundation
/// Runs `brew` (or any executable) and captures stdout/stderr — mock in tests (`CONVENTIONS.md`).
protocol BrewCommandRunning: Sendable {
public protocol BrewCommandRunning: Sendable {
func run(executableURL: URL, arguments: [String]) async throws -> CommandOutput
}
@@ -0,0 +1,11 @@
//
// BrewExecutableLocating.swift
// BrewCore
//
import Foundation
/// Resolves the `brew` executable — real implementation is `BrewExecutableLocator` in `BrewCLI`; tests may inject fakes (`CONVENTIONS.md` — Testing).
public protocol BrewExecutableLocating: Sendable {
func findBrewExecutable() throws -> URL
}
@@ -1,11 +1,11 @@
//
// BrewLookupError.swift
// Brew
// BrewCore
//
import Foundation
/// Could not locate a `brew` executable in supported locations.
nonisolated enum BrewLookupError: Error, Equatable {
public enum BrewLookupError: Error, Equatable, Sendable {
case executableNotFound
}
@@ -1,14 +1,14 @@
//
// BrewMutatingCommand.swift
// Brew
// BrewCore
//
import Foundation
/// One schedulable unit of mutating Homebrew work (command pattern); implemented by small `Sendable` types with `run(in:)`.
protocol BrewMutatingCommand: Sendable {
public protocol BrewMutatingCommand: Sendable {
/// Kind of mutating work — drives ``BrewOperationPhase/running(_:)`` when this command is scheduled.
nonisolated var operationKind: BrewOperationKind { get }
var operationKind: BrewOperationKind { get }
func run(in context: BrewCommandExecutionContext) async throws
}
@@ -0,0 +1,16 @@
//
// BrewMutatingCommandFactory.swift
// BrewCore
//
import Foundation
/// Builds concrete ``BrewMutatingCommand`` values without exposing their types to callers.
///
/// View models depend on this port (an abstraction over `BrewCore` value types) rather than importing
/// the concrete command types in `BrewCLI`, then hand the result to ``BrewCommandCenter/submit(id:command:)``.
public protocol BrewMutatingCommandFactory: Sendable {
func installCommand(kind: HomebrewPackageKind, name: String) -> any BrewMutatingCommand
func upgradeCommand(kind: HomebrewPackageKind, name: String) -> any BrewMutatingCommand
func uninstallCommand(kind: HomebrewPackageKind, name: String) -> any BrewMutatingCommand
}
@@ -1,22 +1,22 @@
//
// BrewOperationID+Homebrew.swift
// Brew
// BrewCore
//
import Foundation
nonisolated extension BrewOperationID {
public extension BrewOperationID {
/// Operation identity from a domain package kind + name.
init(kind: HomebrewPackageKind, name: String) {
switch kind {
case .formula:
packageID = .formula(name: name)
self.init(packageID: .formula(name: name))
case .cask:
packageID = .cask(token: name)
self.init(packageID: .cask(token: name))
}
}
init(package: InstalledBrewPackage) {
packageID = HomebrewPackageID(installedPackage: package)
self.init(packageID: HomebrewPackageID(installedPackage: package))
}
}
@@ -1,12 +1,12 @@
//
// BrewOperationModels.swift
// Brew
// BrewCore
//
import Foundation
/// Mutating Homebrew work the command center may schedule (extend as features grow).
nonisolated enum BrewOperationKind: String, Hashable {
public enum BrewOperationKind: String, Hashable, Sendable {
case installFormula
case installCask
case upgradeFormula
@@ -18,17 +18,21 @@ nonisolated enum BrewOperationKind: String, Hashable {
/// Stable identity for in-flight mutating work (e.g. upgrades), backed by the canonical
/// ``HomebrewPackageID``. There is one mutating operation per package at a time, so the package
/// identity *is* the operation key — no stringly-typed `kind:name` encoding to parse.
nonisolated struct BrewOperationID: Hashable, Identifiable {
let packageID: HomebrewPackageID
public struct BrewOperationID: Hashable, Identifiable, Sendable {
public let packageID: HomebrewPackageID
var id: HomebrewPackageID {
public var id: HomebrewPackageID {
packageID
}
public init(packageID: HomebrewPackageID) {
self.packageID = packageID
}
}
/// Visibility for UI and tests — mutually exclusive with “absent” represented by ``BrewCommandCenter/phase(for:)``
/// returning ``BrewOperationPhase/idle`` when the center has no record for that id.
nonisolated enum BrewOperationPhase: Equatable {
public enum BrewOperationPhase: Equatable, Sendable {
case idle
case running(BrewOperationKind)
case failed(reason: OperationFailure)
@@ -0,0 +1,18 @@
//
// CommandOutput.swift
// BrewCore
//
import Foundation
public struct CommandOutput: Equatable, Sendable {
public var standardOutput: String
public var standardError: String
public var terminationStatus: Int32
public init(standardOutput: String, standardError: String, terminationStatus: Int32) {
self.standardOutput = standardOutput
self.standardError = standardError
self.terminationStatus = terminationStatus
}
}
@@ -1,12 +1,12 @@
//
// OperationFailure.swift
// Brew
// BrewCore
//
import Foundation
/// User-visible and diagnostic failure surfaced through ``BrewOperationPhase/failed(reason:)`` (`Sendable` for actor-isolated state).
nonisolated enum OperationFailure: Equatable {
public enum OperationFailure: Equatable, Sendable {
/// `brew` ran but exited non-zero; stderr is the primary user-visible detail when present.
case brewCommand(exitCode: Int32, stderr: String)
@@ -20,7 +20,7 @@ nonisolated enum OperationFailure: Equatable {
case generic(userFacing: String, diagnostic: String?)
/// Primary line for UI and accessibility (derived per case).
var userFacingMessage: String {
public var userFacingMessage: String {
switch self {
case let .brewCommand(_, stderr):
let trimmed = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -46,11 +46,11 @@ nonisolated enum OperationFailure: Equatable {
}
}
init(userFacingMessage: String, diagnosticDescription: String? = nil) {
public init(userFacingMessage: String, diagnosticDescription: String? = nil) {
self = .generic(userFacing: userFacingMessage, diagnostic: diagnosticDescription)
}
init(catching error: Error) {
public init(catching error: Error) {
switch error {
case let brewCommandError as BrewCommandError:
switch brewCommandError {
@@ -1,14 +1,14 @@
//
// InstalledBrewVersionFormatting.swift
// Brew
// BrewCore
//
import Foundation
/// Display-only version rules for installed list rows (used from `InstalledViewModel`, not repositories).
nonisolated enum InstalledBrewVersionFormatting {
/// Display-only version rules shared by installed and discover surfaces (used from view models, not repositories).
public enum InstalledBrewVersionFormatting {
/// Applies the same prefix rules as listed installed versions (`v` only when absent).
static func displayVersionLabel(trimmedRaw: String) -> String {
public static func displayVersionLabel(trimmedRaw: String) -> String {
if trimmedRaw.hasPrefix("v") || trimmedRaw.hasPrefix("V") {
return trimmedRaw
}
@@ -16,7 +16,7 @@ nonisolated enum InstalledBrewVersionFormatting {
}
/// Optional raw tap/stable string → display label; nil when missing or whitespace-only after trim.
static func upgradeDisplayLabel(from raw: String?) -> String? {
public static func upgradeDisplayLabel(from raw: String?) -> String? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else {
return nil
@@ -1,6 +1,6 @@
//
// LoadState.swift
// Brew
// BrewCore
//
import Foundation
@@ -10,12 +10,12 @@ import Foundation
/// `failed` carries a typed `Failure` (e.g. an `Error` surfaced by a repository); presentation layers
/// map it to user-facing copy. The producer keeps prior `loaded` data on screen when a refresh fails,
/// only transitioning to `failed` when there is nothing to show.
enum LoadState<Value, Failure> {
public enum LoadState<Value, Failure> {
case loading
case loaded(Value)
case failed(Failure)
var value: Value? {
public var value: Value? {
guard case let .loaded(value) = self else {
return nil
}
@@ -0,0 +1,15 @@
//
// Placeholdable.swift
// BrewCore
//
import Foundation
/// A type that can supply a representative placeholder value for redacted loading states.
///
/// Conforming types return a `placeholder` populated with realistic stub data (non-empty name,
/// plausible version, etc.) so a `.redacted(reason: .placeholder)` rendering sizes the same as the
/// eventual content.
public protocol Placeholdable {
static var placeholder: Self { get }
}
@@ -1,6 +1,12 @@
@testable import Brew
//
// BrewPackageFixtures.swift
// BrewCoreTestSupport
//
extension BrewPackage {
import BrewCore
import Foundation
public extension BrewPackage {
static func fixture(
name: String = "git",
displayName: String? = nil,
@@ -22,13 +28,7 @@ extension BrewPackage {
}
}
extension [BrewPackage] {
static var empty: [BrewPackage] {
[]
}
}
extension InstalledBrewPackage {
public extension InstalledBrewPackage {
static func fixture(
name: String = "git",
displayName: String? = nil,
@@ -55,9 +55,3 @@ extension InstalledBrewPackage {
)
}
}
extension [InstalledBrewPackage] {
static var empty: [InstalledBrewPackage] {
[]
}
}
@@ -0,0 +1,137 @@
//
// StubURLProtocol.swift
// BrewCoreTestSupport
//
import Foundation
public func makeStubbedSession() -> URLSession {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [StubURLProtocol.self]
return URLSession(configuration: configuration)
}
public func makeStubBaseURL() -> URL {
URL(string: "https://stub-\(UUID().uuidString).local") ?? URL(fileURLWithPath: "/")
}
public final class StubURLProtocol: URLProtocol {
public enum StubbedResult {
case successWithStatus(data: Data, statusCode: Int, headers: [String: String] = [:])
case successWithResponse(data: Data, response: URLResponse)
case failure(any Error)
}
/// Mutation is serialized by `lock`; opt out of the global-mutable-state check rather than
/// re-expressing the lock through `Mutex` for test-only stubbing.
private static let lock = NSLock()
// swiftlint:disable:next nonisolated_unsafe
private nonisolated(unsafe) static var queuedResultsByHost: [String: [StubbedResult]] = [:]
// swiftlint:disable:next nonisolated_unsafe
private nonisolated(unsafe) static var queuedResultsByHostAndPath: [String: [String: [StubbedResult]]] = [:]
// swiftlint:disable:next nonisolated_unsafe
private nonisolated(unsafe) static var repeatingResultsByHostAndPath: [String: [String: StubbedResult]] = [:]
// swiftlint:disable:next nonisolated_unsafe
private nonisolated(unsafe) static var requestsByHost: [String: [URLRequest]] = [:]
public static func register(_ results: [StubbedResult], forHost host: String) {
lock.lock()
queuedResultsByHost[host] = results
queuedResultsByHostAndPath[host] = [:]
repeatingResultsByHostAndPath[host] = [:]
requestsByHost[host] = []
lock.unlock()
}
/// Registers one stub per URL path so concurrent requests get the correct payload regardless of completion order.
public static func registerByPath(_ resultsByPath: [String: StubbedResult], forHost host: String) {
lock.lock()
queuedResultsByHost[host] = []
queuedResultsByHostAndPath[host] = resultsByPath.mapValues { [$0] }
repeatingResultsByHostAndPath[host] = [:]
requestsByHost[host] = []
lock.unlock()
}
/// Same as `registerByPath`, but stubs are reused for every matching request (for stress tests).
public static func registerRepeatingByPath(_ resultsByPath: [String: StubbedResult], forHost host: String) {
lock.lock()
queuedResultsByHost[host] = []
queuedResultsByHostAndPath[host] = [:]
repeatingResultsByHostAndPath[host] = resultsByPath
requestsByHost[host] = []
lock.unlock()
}
public static func requests(forHost host: String) -> [URLRequest] {
lock.lock()
let requests = requestsByHost[host] ?? []
lock.unlock()
return requests
}
// swiftlint:disable:next static_over_final_class
override public class func canInit(with _: URLRequest) -> Bool {
true
}
// swiftlint:disable:next static_over_final_class
override public class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override public func startLoading() {
guard let host = request.url?.host else {
client?.urlProtocol(self, didFailWithError: URLError(.badURL))
return
}
let result: StubbedResult
Self.lock.lock()
Self.requestsByHost[host, default: []].append(request)
if let path = request.url?.path,
let repeatingResults = Self.repeatingResultsByHostAndPath[host],
let repeatingResult = repeatingResults[path]
{
result = repeatingResult
} else if let path = request.url?.path,
var pathQueues = Self.queuedResultsByHostAndPath[host],
var pathQueue = pathQueues[path],
!pathQueue.isEmpty
{
result = pathQueue.removeFirst()
pathQueues[path] = pathQueue.isEmpty ? nil : pathQueue
Self.queuedResultsByHostAndPath[host] = pathQueues
} else {
var queuedResults = Self.queuedResultsByHost[host] ?? []
if queuedResults.isEmpty {
result = .failure(URLError(.badServerResponse))
} else {
result = queuedResults.removeFirst()
Self.queuedResultsByHost[host] = queuedResults
}
}
Self.lock.unlock()
switch result {
case let .successWithStatus(data, statusCode, headers):
if let url = request.url,
let response = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: headers)
{
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} else {
client?.urlProtocol(self, didFailWithError: URLError(.badURL))
}
case let .successWithResponse(data, response):
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
case let .failure(error):
client?.urlProtocol(self, didFailWithError: error)
}
}
override public func stopLoading() {}
}
@@ -5,7 +5,7 @@
import Foundation
extension UserDefaults {
public extension UserDefaults {
/// Removes every key in `UserDefaults.standard` (or whichever instance) that starts with
/// the given prefix. Used by tests that isolate state with a per-test prefix instead of a
/// suite name, so cleanup doesn't depend on `removePersistentDomain`.
@@ -0,0 +1,48 @@
//
// CommandJob+Presentation.swift
// BrewFeatureConsole
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import Foundation
extension CommandJob {
/// Status-dot state for this job: running until terminal, then success/failure by exit code.
/// Single source of truth for the dot — both the status bar and the toolbar pill bind to it.
var dotState: ConsoleStatusPresentation.DotState {
if !isTerminal {
return .running
}
return succeeded ? .succeeded : .failed
}
/// Plain-text dump of the output buffer for clipboard / save use. `stderr` lines get a `[stderr] ` prefix
/// so a reader can disambiguate without losing the timeline.
func formattedOutputForExport() -> String {
output.map { line in
switch line.stream {
case .stdout:
line.text
case .stderr:
"[stderr] \(line.text)"
}
}
.joined(separator: "\n")
}
/// Suggested filename when saving this job's output to disk.
/// Pattern: `brewui-<sanitized-command>-<yyyy-MM-dd-HHmmss>.log`.
func suggestedExportFilename(now: Date = Date()) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
let timestamp = formatter.string(from: now)
let sanitized = command
.replacingOccurrences(of: " ", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: ":", with: "_")
return "brewui-\(sanitized)-\(timestamp).log"
}
}
@@ -3,11 +3,14 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import Foundation
/// View-facing snapshot of "what does the collapsed status bar render right now" — keeps the view passive.
/// Derived by ``ConsoleViewModel/statusPresentation``.
nonisolated struct ConsoleStatusPresentation: Equatable {
struct ConsoleStatusPresentation: Equatable {
let dotState: DotState
let summary: Summary
let isRunning: Bool
@@ -26,7 +29,7 @@ nonisolated struct ConsoleStatusPresentation: Equatable {
}
}
nonisolated extension BrewOperationPhase {
extension BrewOperationPhase {
/// Plain-English label for surfaces (status bar, inline card).
/// The center's phase enum is coarser than brew's stdout (`fetching`/`pouring`/`linking`) so this
/// stays at the operation-lifecycle level. Sub-phase granularity would require stdout parsing.
@@ -3,6 +3,9 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import Foundation
import Observation
@@ -3,6 +3,9 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import SwiftUI
/// Output area of the expanded console — virtualizing `List` over the selected job's output buffer
@@ -3,13 +3,18 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import SwiftUI
/// View menu commands for the command console. Currently a single `⌘\`` toggle matching Xcode / VS Code / Terminal.
struct ConsoleCommands: Commands {
public struct ConsoleCommands: Commands {
@FocusedBinding(\.consoleExpanded) private var expanded: Bool?
var body: some Commands {
public init() {}
public var body: some Commands {
CommandGroup(after: .toolbar) {
Button(expanded == true ? "Hide Console" : "Show Console") {
expanded?.toggle()
@@ -4,6 +4,9 @@
//
import AppKit
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
/// View-layer AppKit bridge for saving/copying a command job's output. Lives at the view layer so the
/// console view model stays AppKit-free; views call these static methods with the data already shaped by
@@ -3,6 +3,9 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import SwiftUI
/// Bottom-of-window console. Collapsed → status strip only. Expanded → toolbar + output body.
@@ -3,17 +3,25 @@
// Brew
//
import BrewAppEnvironment
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import SwiftUI
/// App-window-owned root for the console feature: reads the shared command-jobs repository from the
/// environment and constructs the per-window ``ConsoleViewModel`` passed down to the panel's subviews.
/// Resize is owned by the parent ``VSplitView`` in ``MainWindowView`` — the panel itself no longer
/// tracks a height, it just renders for whatever size the split view gives it.
struct ConsolePanelRoot: View {
public struct ConsolePanelRoot: View {
@Binding var expanded: Bool
@Environment(\.commandJobsRepository) private var commandJobsRepository
var body: some View {
public init(expanded: Binding<Bool>) {
_expanded = expanded
}
public var body: some View {
ConsolePanel(
expanded: $expanded,
commandJobsRepository: commandJobsRepository,
@@ -3,6 +3,9 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import SwiftUI
/// Always-visible collapsed strip showing the active (or most recent) brew operation
@@ -3,6 +3,9 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import SwiftUI
/// 8pt status indicator for the console strip. Color follows design-system §4.3/§4.4:
@@ -3,6 +3,9 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import SwiftUI
/// Expanded-console toolbar: selected-job pill on the left, Save/Copy/Clear + collapse chevron on the right.
@@ -3,9 +3,12 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import SwiftUI
extension FocusedValues {
public extension FocusedValues {
/// Per-window expand/collapse binding for the console. Published by ``MainWindowView`` via `focusedSceneValue`;
/// consumed by ``ConsoleCommands`` so `⌘\`` toggles the active window's strip.
@Entry var consoleExpanded: Binding<Bool>?
@@ -3,6 +3,9 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import Foundation
/// Derived presentation for "install in progress" chrome on a Discover row.
@@ -12,7 +15,7 @@ import Foundation
/// Instead, bridge the gap between the operation finishing and the installed badge appearing by
/// reading the observable installed-state: stay busy while the operation is running, and keep busy
/// after it finishes until the package is observed as installed.
nonisolated enum DiscoverInstallBusyPresentation {
enum DiscoverInstallBusyPresentation {
static func showsInstallBusy(
phase: BrewOperationPhase,
awaitingResolution: Bool,
@@ -25,7 +28,7 @@ nonisolated enum DiscoverInstallBusyPresentation {
}
}
nonisolated extension BrewOperationPhase {
extension BrewOperationPhase {
var isRunningInstall: Bool {
switch self {
case .running(.installFormula), .running(.installCask):
@@ -1,3 +1,6 @@
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import Foundation
import Observation
@@ -1,3 +1,6 @@
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import Foundation
import Observation
@@ -7,6 +10,7 @@ final class DiscoverPackageDetailViewModel {
private(set) var discoveryPackage: DiscoveryBrewPackage
@ObservationIgnored private let installedRepository: any InstalledPackageStatusReading
@ObservationIgnored private let brewCommandCenter: any BrewCommandCenter
@ObservationIgnored private let commandFactory: any BrewMutatingCommandFactory
@ObservationIgnored private var mutationTask: Task<Void, Never>?
/// Latest phase from the command center stream (see ``observeInstallUpdates()``); drives install chrome.
@@ -30,10 +34,12 @@ final class DiscoverPackageDetailViewModel {
package: DiscoveryBrewPackage,
installedRepository: any InstalledPackageStatusReading,
brewCommandCenter: any BrewCommandCenter,
commandFactory: any BrewMutatingCommandFactory,
) {
discoveryPackage = package
self.installedRepository = installedRepository
self.brewCommandCenter = brewCommandCenter
self.commandFactory = commandFactory
}
private var installedPackage: InstalledBrewPackage? {
@@ -123,7 +129,7 @@ final class DiscoverPackageDetailViewModel {
}
installErrorMessage = nil
let operationID = BrewOperationID(kind: packageKind, name: discoveryPackage.name)
let command = PackageInstallCommand(package: discoveryPackage)
let command = commandFactory.installCommand(kind: packageKind, name: discoveryPackage.name)
mutationTask?.cancel()
mutationTask = Task { @MainActor [self] in
do {
@@ -1,3 +1,6 @@
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import Foundation
import Observation
@@ -1,21 +1,20 @@
import BrewAppEnvironment
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import Foundation
import SwiftUI
/// Entry-point wrapper for the Discover tab content.
struct DiscoverColumnsRoot: View {
@Environment(\.catalogueCache) private var catalogueCache
/// Entry-point wrapper for the Discover tab content. Reads the discover/catalogue/installed
/// repositories from the environment (composed by the app's composition root).
public struct DiscoverColumnsRoot: View {
@Environment(\.discoverPackagesRepository) private var discoverPackagesRepository
@Environment(\.catalogueRepository) private var catalogueRepository
@Environment(\.installedPackagesRepository) private var installedPackagesRepository
var body: some View {
let apiClient = URLSessionBrewAPIClient.live()
let catalogueRepository = BrewCatalogueRepository(
apiClient: apiClient,
cache: catalogueCache,
)
let discoverPackagesRepository = BrewDiscoverPackagesRepository(
apiClient: apiClient,
catalogueRepository: catalogueRepository,
)
public init() {}
public var body: some View {
DiscoverColumns(
discoverPackagesRepository: discoverPackagesRepository,
catalogueRepository: catalogueRepository,
@@ -74,10 +73,13 @@ struct DiscoverColumns: View {
}
}
#Preview {
DiscoverColumns(
discoverPackagesRepository: AppPreviewSupport.makeDiscoverPackagesRepository(),
catalogueRepository: AppPreviewSupport.makeDiscoverCatalogueRepository(),
installedRepository: AppPreviewSupport.makeInstalledPackagesRepository(),
)
}
#if DEBUG
#Preview {
DiscoverColumns(
discoverPackagesRepository: PreviewSupport.makeDiscoverPackagesRepository(),
catalogueRepository: PreviewSupport.makeDiscoverCatalogueRepository(),
installedRepository: PreviewSupport.makeInstalledPackagesRepository(),
)
}
#endif
@@ -1,3 +1,6 @@
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import SwiftUI
/// Installed status pill shared by discover list rows and package detail.
@@ -1,3 +1,7 @@
import BrewAppEnvironment
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import SwiftUI
/// Owns one Discover row's view model, reading the shared installed-status source from the environment at
@@ -151,12 +155,15 @@ struct DiscoverListRowView: View {
}
}
#Preview("Installed") {
DiscoverListRowView(
discoveryPackage: AppPreviewSupport.discoverPreviewPackage,
installedRepository: AppPreviewSupport.makeInstalledPackagesRepository(),
brewCommandCenter: AppPreviewSupport.commandCenter,
)
.padding()
.frame(width: 440)
}
#if DEBUG
#Preview("Installed") {
DiscoverListRowView(
discoveryPackage: PreviewSupport.discoverPreviewPackage,
installedRepository: PreviewSupport.makeInstalledPackagesRepository(),
brewCommandCenter: PreviewSupport.commandCenter,
)
.padding()
.frame(width: 440)
}
#endif
@@ -1,17 +1,24 @@
import AppKit
import BrewAppEnvironment
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import SwiftUI
/// Root view for the selected row; detail content reads ``EnvironmentValues/brewCommandCenter`` and builds relationship repositories from ``EnvironmentValues/installedInventoryCache``.
/// Root view for the selected row; reads the installed repository, command center, and command
/// factory from the environment and injects them into the detail content's view model.
struct DiscoverPackageDetailRoot: View {
let selectedPackage: DiscoveryBrewPackage
@Environment(\.installedPackagesRepository) private var installedPackagesRepository
@Environment(\.brewCommandCenter) private var brewCommandCenter
@Environment(\.mutatingCommandFactory) private var mutatingCommandFactory
var body: some View {
DiscoverPackageDetailView(
package: selectedPackage,
installedRepository: installedPackagesRepository,
brewCommandCenter: brewCommandCenter,
mutatingCommandFactory: mutatingCommandFactory,
)
}
}
@@ -28,6 +35,7 @@ struct DiscoverPackageDetailView: View {
package: DiscoveryBrewPackage,
installedRepository: any InstalledPackageStatusReading,
brewCommandCenter: any BrewCommandCenter,
mutatingCommandFactory: any BrewMutatingCommandFactory,
) {
self.package = package
_viewModel = State(
@@ -35,6 +43,7 @@ struct DiscoverPackageDetailView: View {
package: package,
installedRepository: installedRepository,
brewCommandCenter: brewCommandCenter,
commandFactory: mutatingCommandFactory,
),
)
}
@@ -263,11 +272,15 @@ struct DiscoverPackageDetailPlaceholder: View {
}
}
#Preview {
DiscoverPackageDetailView(
package: AppPreviewSupport.discoverPreviewPackage,
installedRepository: AppPreviewSupport.makeInstalledPackagesRepository(),
brewCommandCenter: AppPreviewSupport.commandCenter,
)
.frame(minWidth: 380, minHeight: 480)
}
#if DEBUG
#Preview {
DiscoverPackageDetailView(
package: PreviewSupport.discoverPreviewPackage,
installedRepository: PreviewSupport.makeInstalledPackagesRepository(),
brewCommandCenter: PreviewSupport.commandCenter,
mutatingCommandFactory: PreviewSupport.mutatingCommandFactory,
)
.frame(minWidth: 380, minHeight: 480)
}
#endif
@@ -1,3 +1,6 @@
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import SwiftUI
/// Middle column of the main window: Discover package list.
@@ -3,6 +3,9 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import Foundation
import Observation
@@ -3,6 +3,9 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import Foundation
import Observation
@@ -10,6 +13,7 @@ import Observation
@MainActor
final class InstalledPackageDetailViewModel {
private let brewCommandCenter: any BrewCommandCenter
private let commandFactory: any BrewMutatingCommandFactory
private let installedDependentsRepository: any InstalledDependentsRepository
private let installedInventoryReading: any InstalledInventoryReading
private var mutationTask: Task<Void, Never>?
@@ -88,11 +92,13 @@ final class InstalledPackageDetailViewModel {
init(
package: InstalledBrewPackage,
brewCommandCenter: any BrewCommandCenter,
commandFactory: any BrewMutatingCommandFactory,
installedDependentsRepository: any InstalledDependentsRepository,
installedInventoryReading: any InstalledInventoryReading,
) {
self.package = package
self.brewCommandCenter = brewCommandCenter
self.commandFactory = commandFactory
self.installedDependentsRepository = installedDependentsRepository
self.installedInventoryReading = installedInventoryReading
}
@@ -135,7 +141,7 @@ final class InstalledPackageDetailViewModel {
func upgradeSelectedPackage() {
let operationID = BrewOperationID(kind: package.kind, name: package.name)
let command = PackageUpgradeCommand(kind: package.kind, name: package.name)
let command = commandFactory.upgradeCommand(kind: package.kind, name: package.name)
submitMutation(
action: .upgrade,
operationID: operationID,
@@ -145,7 +151,7 @@ final class InstalledPackageDetailViewModel {
func uninstallSelectedPackage() {
let operationID = BrewOperationID(kind: package.kind, name: package.name)
let command = PackageUninstallCommand(kind: package.kind, name: package.name)
let command = commandFactory.uninstallCommand(kind: package.kind, name: package.name)
submitMutation(
action: .uninstall,
operationID: operationID,
@@ -3,6 +3,9 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import Foundation
/// Derived presentation for "uninstall in progress" chrome when observing ``BrewOperationPhase`` for an installed row.
@@ -15,7 +18,7 @@ import Foundation
/// - The phase transitions to ``BrewOperationPhase/failed(reason:)`` (error is surfaced instead), or
/// - ``InstalledListRowViewModel/update(package:)`` / ``InstalledPackageDetailViewModel/update(package:)``
/// resets `operationPhase` to ``BrewOperationPhase/idle`` after the catalog refresh propagates.
nonisolated enum InstalledUninstallBusyPresentation {
enum InstalledUninstallBusyPresentation {
static func showsUninstallBusy(
oldPhase: BrewOperationPhase,
newPhase: BrewOperationPhase,
@@ -30,7 +33,7 @@ nonisolated enum InstalledUninstallBusyPresentation {
}
}
private nonisolated extension BrewOperationPhase {
private extension BrewOperationPhase {
var isRunningUninstall: Bool {
switch self {
case .running(.uninstallFormula), .running(.uninstallCask):
@@ -3,6 +3,9 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import Foundation
/// Derived presentation for "upgrade in progress" chrome when observing ``BrewOperationPhase`` for an installed row.
@@ -12,7 +15,7 @@ import Foundation
/// keep showing busy state.
///
/// Only upgrade operation kinds are matched; uninstall busy state is handled by ``InstalledUninstallBusyPresentation``.
nonisolated enum InstalledUpgradeBusyPresentation {
enum InstalledUpgradeBusyPresentation {
static func showsUpgradeBusy(
oldPhase: BrewOperationPhase,
newPhase: BrewOperationPhase,
@@ -28,7 +31,7 @@ nonisolated enum InstalledUpgradeBusyPresentation {
}
}
private nonisolated extension BrewOperationPhase {
private extension BrewOperationPhase {
var isRunningUpgrade: Bool {
switch self {
case .running(.upgradeFormula), .running(.upgradeCask):
@@ -3,10 +3,13 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import Foundation
import Observation
nonisolated struct InstalledPackagesContent: Equatable {
struct InstalledPackagesContent: Equatable {
var packages: [InstalledBrewPackage]
var shouldShowFormulaeSection: Bool {
@@ -26,7 +29,7 @@ nonisolated struct InstalledPackagesContent: Equatable {
}
}
nonisolated enum InstalledLoadState: Equatable {
enum InstalledLoadState: Equatable {
case loading
case loaded(InstalledPackagesContent)
case error(String)
@@ -3,10 +3,13 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import Foundation
/// Presentation mapping for Installed detail metadata content.
nonisolated struct PackageDetailMetadataItem {
struct PackageDetailMetadataItem {
private let package: InstalledBrewPackage
init(package: InstalledBrewPackage) {
@@ -3,6 +3,9 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import Foundation
/// One dependency or dependent row in installed package detail.
@@ -3,10 +3,13 @@
// Brew
//
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import Foundation
/// Presentation mapping for uninstall actions shown in Installed package detail subviews.
nonisolated struct UninstallPackageItem {
struct UninstallPackageItem {
private let package: InstalledBrewPackage
private let blockingDependentCount: Int
@@ -118,13 +121,13 @@ nonisolated struct UninstallPackageItem {
}
/// Copy shown in the uninstall-blocked warning callout.
nonisolated struct UninstallBlockedCalloutContent: Equatable {
struct UninstallBlockedCalloutContent: Equatable {
let lead: String
let body: String
}
/// Result of activating the primary uninstall control in Installed detail.
nonisolated enum UninstallPrimaryButtonAction: Equatable {
enum UninstallPrimaryButtonAction: Equatable {
case presentConfirmation
case revealBlockedExplanation
}

Some files were not shown because too many files have changed in this diff Show More