From 7b3468126a9426c42a74b552c56e6caa039d68cc Mon Sep 17 00:00:00 2001 From: maxsoch Date: Mon, 6 Jul 2026 22:26:12 +0200 Subject: [PATCH 1/3] add typed JSON parser for brew outdated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseOutdatedJSON decodes brew outdated --json into OutdatedPackages, which separates formulae from casks — the plain-text output is just names with no type information. Codable structs mirror the JSON shape, with convertFromSnakeCase mapping installed_versions to Swift naming. Undecodable input (brew error text, empty output) parses as .none. The legacy line parser stays until callers switch over next commit. Covered by 4 new unit tests. Co-Authored-By: Claude Fable 5 --- BrewBar/BrewParser.swift | 46 ++++++++++++++++++++++++++++++ BrewBarTests/BrewBarTests.swift | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/BrewBar/BrewParser.swift b/BrewBar/BrewParser.swift index ef97bd0..b8d0c32 100644 --- a/BrewBar/BrewParser.swift +++ b/BrewBar/BrewParser.swift @@ -1,5 +1,36 @@ import Foundation +struct OutdatedPackage: Codable, Equatable { + let name: String + let installedVersions: [String] + let currentVersion: String + + /// Menu label, e.g. "wget 1.21 → 1.22" + var label: String { + let installed = installedVersions.joined(separator: ", ") + return "\(name) \(installed) → \(currentVersion)" + } +} + +struct OutdatedPackages: Codable, Equatable { + let formulae: [OutdatedPackage] + let casks: [OutdatedPackage] + + var all: [OutdatedPackage] { + formulae + casks + } + + var isEmpty: Bool { + formulae.isEmpty && casks.isEmpty + } + + var count: Int { + formulae.count + casks.count + } + + static let none = OutdatedPackages(formulae: [], casks: []) +} + enum BrewParser { static func parseOutdated(_ output: String) -> [String] { output.split(separator: "\n") @@ -7,6 +38,21 @@ enum BrewParser { .filter { !$0.isEmpty } } + /// Parses `brew outdated --json` output. Returns .none for anything + /// undecodable (brew error text, empty output) — same effect as an + /// empty result, and the raw output is already in the log window. + static func parseOutdatedJSON(_ output: String) -> OutdatedPackages { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase // installed_versions → installedVersions + + guard let data = output.data(using: .utf8), + let parsed = try? decoder.decode(OutdatedPackages.self, from: data) + else { + return .none + } + return parsed + } + static func parseVersion(_ output: String) -> String { output.split(separator: "\n").first.map(String.init) ?? "Unknown" } diff --git a/BrewBarTests/BrewBarTests.swift b/BrewBarTests/BrewBarTests.swift index d3cbdfb..9632e58 100644 --- a/BrewBarTests/BrewBarTests.swift +++ b/BrewBarTests/BrewBarTests.swift @@ -22,6 +22,56 @@ final class BrewParserTests: XCTestCase { XCTAssertEqual(BrewParser.parseOutdated(output), ["wget"]) } + // MARK: - parseOutdatedJSON + + func testParseOutdatedJSON_formulaeAndCasks() { + let output = """ + { + "formulae": [ + { + "name": "wget", + "installed_versions": ["1.21.3"], + "current_version": "1.21.4", + "pinned": false, + "pinned_version": null + } + ], + "casks": [ + { + "name": "tailscale-app", + "installed_versions": ["1.98.5"], + "current_version": "1.98.8", + "pinned": false, + "pinned_version": null + } + ] + } + """ + + let result = BrewParser.parseOutdatedJSON(output) + XCTAssertEqual(result.formulae.map(\.name), ["wget"]) + XCTAssertEqual(result.casks.map(\.name), ["tailscale-app"]) + XCTAssertEqual(result.count, 2) + XCTAssertFalse(result.isEmpty) + XCTAssertEqual(result.all.map(\.name), ["wget", "tailscale-app"]) + } + + func testParseOutdatedJSON_nothingOutdated() { + let result = BrewParser.parseOutdatedJSON("{\"formulae\": [], \"casks\": []}") + XCTAssertTrue(result.isEmpty) + XCTAssertEqual(result.count, 0) + } + + func testParseOutdatedJSON_invalidInputReturnsNone() { + XCTAssertEqual(BrewParser.parseOutdatedJSON(""), .none) + XCTAssertEqual(BrewParser.parseOutdatedJSON("Error: some brew failure"), .none) + } + + func testParseOutdatedJSON_packageLabel() { + let package = OutdatedPackage(name: "wget", installedVersions: ["1.21.3"], currentVersion: "1.21.4") + XCTAssertEqual(package.label, "wget 1.21.3 → 1.21.4") + } + // MARK: - parseVersion func testParseVersion_picksFirstLine() { From 813fc47461a7547c4e58625a221a4fe4cc21daae Mon Sep 17 00:00:00 2001 From: maxsoch Date: Mon, 6 Jul 2026 22:27:37 +0200 Subject: [PATCH 2/3] fetch outdated as JSON; auto-upgrade only for matching package kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchOutdated now uses --json and the typed parser, so cachedOutdated knows formulae from casks. The auto-upgrade trigger checks each kind against its own toggle: an outdated cask no longer starts a pointless "upgrade --formula" run (previously any outdated package triggered whichever auto-upgrades were enabled). runAutoUpgrade takes explicit formulae/casks flags instead of re-reading settings. Bonus from the version info now available: outdated menu items and the Upgrade All confirmation show "name installed → current" instead of bare names. The legacy line parser and its tests are removed. Co-Authored-By: Claude Fable 5 --- BrewBar/BrewBarApp.swift | 46 +++++++++++++++++---------------- BrewBar/BrewParser.swift | 6 ----- BrewBarTests/BrewBarTests.swift | 20 -------------- 3 files changed, 24 insertions(+), 48 deletions(-) diff --git a/BrewBar/BrewBarApp.swift b/BrewBar/BrewBarApp.swift index 3024413..12b4d12 100644 --- a/BrewBar/BrewBarApp.swift +++ b/BrewBar/BrewBarApp.swift @@ -40,7 +40,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } /// last known outdated list, shown in the Upgrade All confirmation - var cachedOutdated: [String] = [] + var cachedOutdated: OutdatedPackages = .none var lastNotifiedOutdated: Set = [] // MARK: - Menu @@ -240,7 +240,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { var info = "Apps upgraded as casks (like a web browser) may be closed and replaced while running, without further warning. Save your work first." if !cachedOutdated.isEmpty { - info += "\n\n" + cachedOutdated.joined(separator: "\n") + info += "\n\n" + cachedOutdated.all.map(\.label).joined(separator: "\n") } alert.informativeText = info @@ -259,25 +259,27 @@ class AppDelegate: NSObject, NSApplicationDelegate { setMenuBarIcon("brewbar-updating") // --greedy also lists casks that self-update (brew skips them by default) - let command = Settings.includeGreedyCasks ? "outdated --greedy" : "outdated" + let command = Settings.includeGreedyCasks ? "outdated --greedy --json" : "outdated --json" runBrew(command) { output in - let lines = BrewParser.parseOutdated(output) + let outdated = BrewParser.parseOutdatedJSON(output) - self.cachedOutdated = lines + self.cachedOutdated = outdated - if allowAutoUpgrade, !lines.isEmpty, - Settings.autoUpgradeFormulae || Settings.autoUpgradeCasks - { - self.runAutoUpgrade() + // only trigger for the kinds that are both enabled AND outdated — + // e.g. an outdated cask must not start a pointless formulae upgrade + let upgradeFormulae = Settings.autoUpgradeFormulae && !outdated.formulae.isEmpty + let upgradeCasks = Settings.autoUpgradeCasks && !outdated.casks.isEmpty + if allowAutoUpgrade, upgradeFormulae || upgradeCasks { + self.runAutoUpgrade(formulae: upgradeFormulae, casks: upgradeCasks) return } - self.updateOutdatedMenu(with: lines) - self.updateStatus(count: lines.count) + self.updateOutdatedMenu(with: outdated) + self.updateStatus(count: outdated.count) self.updateMenuBarCount() - self.notifyIfNeeded(outdated: lines) - if lines.isEmpty { + self.notifyIfNeeded(outdated: outdated.all.map(\.name)) + if outdated.isEmpty { self.setMenuBarIcon("brewbar-uptodate") } else { self.setMenuBarIcon("brewbar-outdated") @@ -303,11 +305,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { UNUserNotificationCenter.current().add(request) } - func runAutoUpgrade() { + func runAutoUpgrade(formulae: Bool, casks: Bool) { statusMenuItem.title = "Auto-upgrading..." let upgradeCasksThenRefresh = { - if Settings.autoUpgradeCasks { + if casks { let command = Settings.includeGreedyCasks ? "upgrade --cask --greedy" : "upgrade --cask" self.runBrew(command) { _ in self.fetchOutdated(allowAutoUpgrade: false) @@ -317,7 +319,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - if Settings.autoUpgradeFormulae { + if formulae { runBrew("upgrade --formula") { _ in upgradeCasksThenRefresh() } @@ -326,14 +328,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - func updateOutdatedMenu(with lines: [String]) { + func updateOutdatedMenu(with outdated: OutdatedPackages) { outdatedSubmenu.removeAllItems() - outdatedItem.isHidden = lines.isEmpty - upgradeAllItem.isHidden = lines.isEmpty + outdatedItem.isHidden = outdated.isEmpty + upgradeAllItem.isHidden = outdated.isEmpty - for formula in lines { - let item = NSMenuItem(title: formula, action: #selector(upgradeSingle(_:)), keyEquivalent: "") - item.representedObject = formula + for package in outdated.all { + let item = NSMenuItem(title: package.label, action: #selector(upgradeSingle(_:)), keyEquivalent: "") + item.representedObject = package.name // the title has versions; brew needs the bare name outdatedSubmenu.addItem(item) } } diff --git a/BrewBar/BrewParser.swift b/BrewBar/BrewParser.swift index b8d0c32..e68fbad 100644 --- a/BrewBar/BrewParser.swift +++ b/BrewBar/BrewParser.swift @@ -32,12 +32,6 @@ struct OutdatedPackages: Codable, Equatable { } enum BrewParser { - static func parseOutdated(_ output: String) -> [String] { - output.split(separator: "\n") - .map { String($0) } - .filter { !$0.isEmpty } - } - /// Parses `brew outdated --json` output. Returns .none for anything /// undecodable (brew error text, empty output) — same effect as an /// empty result, and the raw output is already in the log window. diff --git a/BrewBarTests/BrewBarTests.swift b/BrewBarTests/BrewBarTests.swift index 9632e58..fbff287 100644 --- a/BrewBarTests/BrewBarTests.swift +++ b/BrewBarTests/BrewBarTests.swift @@ -2,26 +2,6 @@ import XCTest final class BrewParserTests: XCTestCase { - // MARK: - parseOutdated - - func testParseOutdated_multiplePackages() { - let output = "wget\nffmpeg\ngit\n" - XCTAssertEqual(BrewParser.parseOutdated(output), ["wget", "ffmpeg", "git"]) - } - - func testParseOutdated_emptyOutput() { - XCTAssertEqual(BrewParser.parseOutdated(""), []) - } - - func testParseOutdated_singlePackage() { - XCTAssertEqual(BrewParser.parseOutdated("wget\n"), ["wget"]) - } - - func testParseOutdated_trailingNewlinesIgnored() { - let output = "wget\n\n\n" - XCTAssertEqual(BrewParser.parseOutdated(output), ["wget"]) - } - // MARK: - parseOutdatedJSON func testParseOutdatedJSON_formulaeAndCasks() { From c296c03ee5a9284689df523dc2a27bee0b21b824 Mon Sep 17 00:00:00 2001 From: maxsoch Date: Mon, 6 Jul 2026 22:28:17 +0200 Subject: [PATCH 3/3] update menu and count before auto-upgrade starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchOutdated returned before refreshing the UI when auto-upgrade triggered, so the menu showed stale state for the whole upgrade. Now the fetched list lands in the menu, status line and menu bar count first; runAutoUpgrade then overwrites the status with its own message. Notification stays after the post-upgrade refetch — leftovers only. Co-Authored-By: Claude Fable 5 --- BrewBar/BrewBarApp.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/BrewBar/BrewBarApp.swift b/BrewBar/BrewBarApp.swift index 12b4d12..2f76d0c 100644 --- a/BrewBar/BrewBarApp.swift +++ b/BrewBar/BrewBarApp.swift @@ -266,6 +266,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { self.cachedOutdated = outdated + // reflect what was found before any auto-upgrade starts, so the + // menu shows the real list while the upgrade is running + self.updateOutdatedMenu(with: outdated) + self.updateStatus(count: outdated.count) + self.updateMenuBarCount() + // only trigger for the kinds that are both enabled AND outdated — // e.g. an outdated cask must not start a pointless formulae upgrade let upgradeFormulae = Settings.autoUpgradeFormulae && !outdated.formulae.isEmpty @@ -275,9 +281,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { return } - self.updateOutdatedMenu(with: outdated) - self.updateStatus(count: outdated.count) - self.updateMenuBarCount() + // notify only about what auto-upgrade didn't (or couldn't) handle self.notifyIfNeeded(outdated: outdated.all.map(\.name)) if outdated.isEmpty { self.setMenuBarIcon("brewbar-uptodate")