add typed JSON parser for brew outdated

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 <noreply@anthropic.com>
This commit is contained in:
maxsoch 2026-07-06 22:26:12 +02:00
parent 3a5a7ee623
commit 7b3468126a
2 changed files with 96 additions and 0 deletions

View file

@ -1,5 +1,36 @@
import Foundation 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 { enum BrewParser {
static func parseOutdated(_ output: String) -> [String] { static func parseOutdated(_ output: String) -> [String] {
output.split(separator: "\n") output.split(separator: "\n")
@ -7,6 +38,21 @@ enum BrewParser {
.filter { !$0.isEmpty } .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 { static func parseVersion(_ output: String) -> String {
output.split(separator: "\n").first.map(String.init) ?? "Unknown" output.split(separator: "\n").first.map(String.init) ?? "Unknown"
} }

View file

@ -22,6 +22,56 @@ final class BrewParserTests: XCTestCase {
XCTAssertEqual(BrewParser.parseOutdated(output), ["wget"]) 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 // MARK: - parseVersion
func testParseVersion_picksFirstLine() { func testParseVersion_picksFirstLine() {