Skip to content

Commit 15db7a3

Browse files
committed
refactor: use enum with associated values for BuildOperationMetrics
Replace the struct with optional fields approach with an enum that clearly distinguishes between Xcode 15.3-16.x (v15_3) and Xcode 26.4+ (v26_4) formats using associated values. This replaces the decodeIfPresent approach from MobileNativeFoundation#246 with a try-decode-fallback strategy that auto-detects the format. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: giginet <giginet.net@gmail.com>
1 parent a360ff3 commit 15db7a3

5 files changed

Lines changed: 88 additions & 57 deletions

File tree

Sources/XCLogParser/activityparser/ActivityParser.swift

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -436,14 +436,12 @@ public class ActivityParser {
436436
backtrace: try parseAsJson(token: iterator.next(),
437437
type: jsonType))
438438
case .some("BuildOperationMetrics"):
439-
let jsonType = IDEActivityLogSectionAttachment.BuildOperationMetrics.self
440439
return try IDEActivityLogSectionAttachment(identifier: identifier,
441440
majorVersion: try parseAsInt(token: iterator.next()),
442441
minorVersion: try parseAsInt(token: iterator.next()),
443442
metrics: nil,
444-
buildOperationMetrics: try parseAsJson(
445-
token: iterator.next(),
446-
type: jsonType
443+
buildOperationMetrics: try parseBuildOperationMetrics(
444+
token: iterator.next()
447445
),
448446
backtrace: nil)
449447
default:
@@ -693,6 +691,25 @@ public class ActivityParser {
693691
}
694692
}
695693

694+
private func parseBuildOperationMetrics(
695+
token: Token?
696+
) throws -> IDEActivityLogSectionAttachment.BuildOperationMetrics? {
697+
guard let token = token else {
698+
throw XCLogParserError.parseError("Unexpected EOF parsing BuildOperationMetrics")
699+
}
700+
switch token {
701+
case .json(let string):
702+
guard let data = string.data(using: .utf8) else {
703+
throw XCLogParserError.parseError("Unexpected JSON string \(string)")
704+
}
705+
return try IDEActivityLogSectionAttachment.BuildOperationMetrics(from: data)
706+
case .null:
707+
return nil
708+
default:
709+
throw XCLogParserError.parseError("Unexpected token parsing BuildOperationMetrics: \(token)")
710+
}
711+
}
712+
696713
private func parseAsInt(token: Token?) throws -> UInt64 {
697714
guard let token = token else {
698715
throw XCLogParserError.parseError("Unexpected EOF parsing Int")

Sources/XCLogParser/activityparser/IDEActivityModel.swift

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -773,30 +773,42 @@ public class IDEActivityLogSectionAttachment: Encodable {
773773
// Empty struct for objects with no properties
774774
}
775775

776-
public struct BuildOperationMetrics: Codable {
777-
public let clangCacheHits: Int
778-
public let clangCacheMisses: Int
779-
public let swiftCacheHits: Int
780-
public let swiftCacheMisses: Int
781-
782-
public init(
783-
clangCacheHits: Int = 0,
784-
clangCacheMisses: Int = 0,
785-
swiftCacheHits: Int = 0,
786-
swiftCacheMisses: Int = 0
787-
) {
788-
self.clangCacheHits = clangCacheHits
789-
self.clangCacheMisses = clangCacheMisses
790-
self.swiftCacheHits = swiftCacheHits
791-
self.swiftCacheMisses = swiftCacheMisses
776+
/// Build operation metrics whose JSON schema differs by Xcode version.
777+
public enum BuildOperationMetrics: Encodable {
778+
/// Xcode 15.3 - Xcode 16.x format
779+
case v15_3(CacheMetrics)
780+
/// Xcode 26.4+ format
781+
case v26_4(CounterMetrics)
782+
783+
public struct CacheMetrics: Codable {
784+
public let clangCacheHits: Int
785+
public let clangCacheMisses: Int
786+
public let swiftCacheHits: Int
787+
public let swiftCacheMisses: Int
792788
}
793789

794-
public init(from decoder: Decoder) throws {
795-
let container = try decoder.container(keyedBy: CodingKeys.self)
796-
clangCacheHits = try container.decodeIfPresent(Int.self, forKey: .clangCacheHits) ?? 0
797-
clangCacheMisses = try container.decodeIfPresent(Int.self, forKey: .clangCacheMisses) ?? 0
798-
swiftCacheHits = try container.decodeIfPresent(Int.self, forKey: .swiftCacheHits) ?? 0
799-
swiftCacheMisses = try container.decodeIfPresent(Int.self, forKey: .swiftCacheMisses) ?? 0
790+
public struct CounterMetrics: Codable {
791+
public let counters: [String: Int]
792+
public let taskCounters: [String: [String: Int]]
793+
}
794+
795+
public init(from jsonData: Data) throws {
796+
let decoder = JSONDecoder()
797+
if let cache = try? decoder.decode(CacheMetrics.self, from: jsonData) {
798+
self = .v15_3(cache)
799+
} else {
800+
let counter = try decoder.decode(CounterMetrics.self, from: jsonData)
801+
self = .v26_4(counter)
802+
}
803+
}
804+
805+
public func encode(to encoder: Encoder) throws {
806+
switch self {
807+
case .v15_3(let metrics):
808+
try metrics.encode(to: encoder)
809+
case .v26_4(let metrics):
810+
try metrics.encode(to: encoder)
811+
}
800812
}
801813
}
802814
}

Tests/XCLogParserTests/ActivityParserTests.swift

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,11 @@ class ActivityParserTests: XCTestCase {
420420
XCTAssertEqual(3, logSection.attachments.count)
421421
XCTAssertEqual(logSection.attachments[0].backtrace?.frames.first?.category, .ruleNeverBuilt)
422422
print(logSection.attachments)
423-
XCTAssertEqual(logSection.attachments[1].buildOperationMetrics?.clangCacheMisses, 2)
423+
if case .some(.v15_3(let cache)) = logSection.attachments[1].buildOperationMetrics {
424+
XCTAssertEqual(cache.clangCacheMisses, 2)
425+
} else {
426+
XCTFail("Expected v15_3 BuildOperationMetrics")
427+
}
424428
XCTAssertEqual(logSection.attachments[2].metrics?.wcDuration, 1)
425429
XCTAssertEqual(0, logSection.unknown)
426430
}
@@ -506,14 +510,12 @@ class ActivityParserTests: XCTestCase {
506510
XCTAssertEqual("localizedResultString", logSection.localizedResultString)
507511
XCTAssertEqual("xcbuildSignature", logSection.xcbuildSignature)
508512
XCTAssertEqual(1, logSection.attachments.count)
509-
let metrics = logSection.attachments[0].buildOperationMetrics
510-
XCTAssertNotNil(metrics)
511-
XCTAssertNil(metrics?.clangCacheHits)
512-
XCTAssertNil(metrics?.clangCacheMisses)
513-
XCTAssertNil(metrics?.swiftCacheHits)
514-
XCTAssertNil(metrics?.swiftCacheMisses)
515-
XCTAssertEqual(metrics?.counters, [:])
516-
XCTAssertEqual(metrics?.taskCounters?["SwiftDriver"]?["moduleDependenciesNotValidatedTasks"], 1)
513+
if case .some(.v26_4(let counter)) = logSection.attachments[0].buildOperationMetrics {
514+
XCTAssertEqual(counter.counters, [:])
515+
XCTAssertEqual(counter.taskCounters["SwiftDriver"]?["moduleDependenciesNotValidatedTasks"], 1)
516+
} else {
517+
XCTFail("Expected v26_4 BuildOperationMetrics")
518+
}
517519
XCTAssertEqual(42, logSection.unknown)
518520
}
519521

@@ -634,30 +636,30 @@ class ActivityParserTests: XCTestCase {
634636
XCTAssertEqual(expectedDVTMemberDocumentLocation, documentMemberLocation)
635637
}
636638

637-
func testBuildOperationMetricsWithMissingKeys() throws {
638-
let json = #"{}"#
639+
func testBuildOperationMetricsWithCacheFormat() throws {
640+
let json = #"{"clangCacheHits":1,"clangCacheMisses":2,"swiftCacheHits":3,"swiftCacheMisses":4}"#
639641
let data = json.data(using: .utf8)!
640-
let metrics = try JSONDecoder().decode(
641-
IDEActivityLogSectionAttachment.BuildOperationMetrics.self,
642-
from: data
643-
)
644-
XCTAssertEqual(metrics.clangCacheHits, 0)
645-
XCTAssertEqual(metrics.clangCacheMisses, 0)
646-
XCTAssertEqual(metrics.swiftCacheHits, 0)
647-
XCTAssertEqual(metrics.swiftCacheMisses, 0)
642+
let metrics = try IDEActivityLogSectionAttachment.BuildOperationMetrics(from: data)
643+
if case .v15_3(let cache) = metrics {
644+
XCTAssertEqual(cache.clangCacheHits, 1)
645+
XCTAssertEqual(cache.clangCacheMisses, 2)
646+
XCTAssertEqual(cache.swiftCacheHits, 3)
647+
XCTAssertEqual(cache.swiftCacheMisses, 4)
648+
} else {
649+
XCTFail("Expected v15_3 BuildOperationMetrics")
650+
}
648651
}
649652

650-
func testBuildOperationMetricsWithPartialKeys() throws {
651-
let json = #"{"swiftCacheHits":5,"swiftCacheMisses":3}"#
653+
func testBuildOperationMetricsWithCounterFormat() throws {
654+
let json = #"{"counters":{"a":1},"taskCounters":{"SwiftDriver":{"x":2}}}"#
652655
let data = json.data(using: .utf8)!
653-
let metrics = try JSONDecoder().decode(
654-
IDEActivityLogSectionAttachment.BuildOperationMetrics.self,
655-
from: data
656-
)
657-
XCTAssertEqual(metrics.clangCacheHits, 0)
658-
XCTAssertEqual(metrics.clangCacheMisses, 0)
659-
XCTAssertEqual(metrics.swiftCacheHits, 5)
660-
XCTAssertEqual(metrics.swiftCacheMisses, 3)
656+
let metrics = try IDEActivityLogSectionAttachment.BuildOperationMetrics(from: data)
657+
if case .v26_4(let counter) = metrics {
658+
XCTAssertEqual(counter.counters["a"], 1)
659+
XCTAssertEqual(counter.taskCounters["SwiftDriver"]?["x"], 2)
660+
} else {
661+
XCTFail("Expected v26_4 BuildOperationMetrics")
662+
}
661663
}
662664

663665
}

docs/JSON Format.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ Aggregate build operation metrics. The JSON format differs by Xcode version:
135135
}
136136
```
137137

138-
All fields in `buildOperationMetrics` are optional to support both formats.
138+
XCLogParser automatically detects and supports both formats.
139139

140140
### BuildOperationTaskBacktrace
141141

docs/Xcactivitylog Format.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,4 @@ Starting with **Xcode 26.4**, the format changed to a dynamic counter-based stru
189189
{"counters":{},"taskCounters":{"SwiftDriver":{"moduleDependenciesNotValidatedTasks":1}}}
190190
```
191191

192-
XCLogParser supports both formats. All fields are optional to maintain backward compatibility.
192+
XCLogParser automatically detects and supports both formats.

0 commit comments

Comments
 (0)