import Foundation /// A build log cut into lines and grouped by step, the way the web folds /// it (`SplitBuildLog` and `FailedSection` in gitbay's /// internal/control/buildlog.go). nonisolated struct BuildLog: Sendable { struct Line: Sendable, Identifiable, Equatable { /// Position in the whole log, so any line can be scrolled to. let id: Int let text: String } /// The setup before the first step (`n` 0), or one step and its /// output. struct Section: Sendable, Identifiable { let n: Int /// The step's command; "" for the setup. let step: String let lines: [Line] var id: Int { n } var head: String { String(step.prefix { $0 != "\n" }) } } let sections: [Section] /// Whether any step's line is in the log. A build that stopped in the /// clone has none, and its log is one block. let stepped: Bool /// Index into `sections` of the step a failed build stopped in. let failed: Int? init(_ text: String, build: Build) { var cuts: [(n: Int, step: String, text: Substring)] = [] var cur = (n: 0, step: "") var start = text.startIndex for (i, step) in (build.steps ?? []).enumerated() { let marker = "$ " + step + "\n" guard let at = Self.findLine(text, marker, from: start) else { break } if cur.n > 0 || at.lowerBound > start { cuts.append((cur.n, cur.step, text[start.. 0 || start < text.endIndex { cuts.append((cur.n, cur.step, text[start...])) } var next = 0 sections = cuts.map { cut in var body = cut.text if body.last?.isNewline == true { body = body.dropLast() } // isNewline also splits "\r\n", which Swift keeps as one // Character, and a progress line's bare "\r". let lines: [Line] = cut.text.isEmpty ? [] : body .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) .map { text in defer { next += 1 } return Line(id: next, text: String(text)) } return Section(n: cut.n, step: cut.step, lines: lines) } stepped = sections.contains { $0.n > 0 } if build.status == "failure", !sections.isEmpty { let step = build.failedStep ?? 0 failed = sections.firstIndex { step > 0 && $0.n == step } ?? sections.count - 1 } else { failed = nil } } var lastLineID: Int? { sections.last { !$0.lines.isEmpty }?.lines.last?.id } /// The sections holding a line that contains `query`, with only those /// lines. All of them when `query` is empty. func filtered(_ query: String) -> [Section] { guard !query.isEmpty else { return sections } return sections.compactMap { section in let hits = section.lines.filter { $0.text.localizedCaseInsensitiveContains(query) } return hits.isEmpty ? nil : Section(n: section.n, step: section.step, lines: hits) } } /// Where `line` occurs at the start of a line of `text`, at or after /// `from`. Literal, so "\n" matches inside "\r\n" as the server's does. private static func findLine(_ text: String, _ line: String, from: String.Index) -> Range? { var from = from while let range = text.range(of: line, options: .literal, range: from..