import Foundation import WireProtocol // T-iOS-27 · App-layer fetcher for `GET /projects/diff?path=&staged=` —— the // read-only structured git diff (server route src/server.ts:601-618, parser // src/http/diff.ts). iOS mirrors the web split: parsing lives ONLY server-side; // this file fetches + tolerantly decodes, the screen renders verbatim. // // 为什么不在 APIClient 包里:APIClient 是 T-iOS-38 单一 owner(PLAN §6 Owns // 铁律),本任务不得改包。此 fetcher 走同一枚注入的 `HTTPTransport`,语义与 // APIClient 路由构建完全同构 —— **回收进 APIClient 是 T-iOS-38 owner 的 // follow-up**(届时本文件的路由/编码/解码可整体搬迁,测试随行)。 // // Origin 铁律(plan §3.4/§5.1,iff-G):`/projects/diff` 是 RO 端点 —— 路由层 // 没有 requireAllowedOrigin(src/server.ts:601 注释 "no Origin guard; same // threat model as /projects"),因此请求**绝不携带 Origin**。若服务器未来把它 // 改为 G,测试先红而不是靠巧合通过。 // // 服务器是不可信输入源(plan §4):解码逐层宽容,镜像 web 的 // normalizeDiffResult(public/diff.ts:38-102)—— 顶层三键缺一 → 整体无效; // 畸形 file/hunk/line 逐条丢弃;未知枚举值降级(kind → .context、status → // .modified),绝不 crash。 // MARK: - 模型(镜像 src/types.ts:445-479;App 内部,待 T-iOS-38 回收) /// One diff line's semantic kind. Unknown wire values degrade to `.context` /// (the web renderer's `?? 'df-context'` fallback, public/diff.ts:192). enum DiffLineKind: String, Sendable, Equatable { case added, removed, context, hunk, meta } struct DiffLine: Sendable, Equatable { let kind: DiffLineKind /// Verbatim server bytes (marker already stripped server-side). UNTRUSTED /// display input — render via `Text(verbatim:)` only, never Markdown/links. let text: String } struct DiffHunk: Sendable, Equatable { let header: String let lines: [DiffLine] } /// File-level change status. Unknown wire values degrade to `.modified` /// (display-only field; the web keeps the raw string for a CSS class). enum DiffFileStatus: String, Sendable, Equatable { case modified, added, deleted, renamed, binary, untracked } struct DiffFile: Sendable, Equatable { let oldPath: String let newPath: String let status: DiffFileStatus let added: Int let removed: Int let binary: Bool let hunks: [DiffHunk] } struct DiffResult: Sendable, Equatable { let files: [DiffFile] let staged: Bool let truncated: Bool } // MARK: - 宽容解码(Decodable 在 extension 里,保留成员逐一初始化器) extension DiffLine: Decodable { private enum CodingKeys: String, CodingKey { case kind, text } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) // kind/text 必须是字符串,否则整行丢弃(镜像 normalizeLine)。 let rawKind = try container.decode(String.self, forKey: .kind) text = try container.decode(String.self, forKey: .text) kind = DiffLineKind(rawValue: rawKind) ?? .context } } extension DiffHunk: Decodable { private enum CodingKeys: String, CodingKey { case header, lines } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) header = try container.decode(String.self, forKey: .header) let boxes = try container.decode([DiffLossyBox].self, forKey: .lines) lines = boxes.compactMap(\.value) } } extension DiffFile: Decodable { private enum CodingKeys: String, CodingKey { case oldPath, newPath, status, added, removed, binary, hunks } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) oldPath = try container.decode(String.self, forKey: .oldPath) newPath = try container.decode(String.self, forKey: .newPath) added = try container.decode(Int.self, forKey: .added) removed = try container.decode(Int.self, forKey: .removed) binary = try container.decode(Bool.self, forKey: .binary) let rawStatus = try container.decode(String.self, forKey: .status) status = DiffFileStatus(rawValue: rawStatus) ?? .modified let boxes = try container.decode([DiffLossyBox].self, forKey: .hunks) hunks = boxes.compactMap(\.value) } } extension DiffResult: Decodable { private enum CodingKeys: String, CodingKey { case files, staged, truncated } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) staged = try container.decode(Bool.self, forKey: .staged) truncated = try container.decode(Bool.self, forKey: .truncated) let boxes = try container.decode([DiffLossyBox].self, forKey: .files) files = boxes.compactMap(\.value) } } /// Per-element tolerance shim: a malformed element becomes nil instead of /// failing the whole array. Local twin of APIClient's internal `LossyBox` /// (not importable across the package boundary) — folds together with the /// fetcher in the T-iOS-38 follow-up. private struct DiffLossyBox: Decodable { let value: Wrapped? init(from decoder: any Decoder) { value = try? Wrapped(from: decoder) } } // MARK: - 错误分类学(route 层语义,src/server.ts:602-618) enum DiffFetchError: Error, Equatable { /// 空 path / 无法编码 / URL 构建失败 —— 客户端先拒,零网络。 case invalidRequest /// 服务器 400:`path query parameter is required`。 case pathInvalid /// 服务器 404:SEC-H7 三叉校验失败(绝对路径 + 目录 + 有 .git)。 case projectNotFound /// 200 但响应体不是 DiffResult 形状(不可信输入源兜底)。 case invalidResponse /// 其余状态(含 500 `failed to read diff`)。 case unexpectedStatus(Int) } // MARK: - DiffFetcher /// Fetch a repo's structured diff over the injected transport. Immutable /// value; one instance per (endpoint) — the screen's VM closes over it. struct DiffFetcher: Sendable { let endpoint: HostEndpoint private let http: any HTTPTransport /// Route constants(无魔法字符串)。`stagedFlag`:服务器精确匹配 /// `req.query['staged'] === '1'`(src/server.ts:613)——必须发 `1`/`0`。 /// (对照:web fetchDiff 发 `true`/`false`,永远匹配不上 `'1'` —— /// staged 开关在 web 端静默失效,已在任务日志上报,不属本任务 Owns。) private enum Route { static let path = "/projects/diff" static let pathKey = "path" static let stagedKey = "staged" static let stagedOn = "1" static let stagedOff = "0" static let methodGet = "GET" } private enum Status { static let ok = 200 static let badRequest = 400 static let notFound = 404 } /// Strict RFC 3986 unreserved set —— 与 APIClient `Endpoints. /// unreservedCharacters` 同值同理(裸 `+` 会被 Express qs 解码成空格, /// `&`/`=` 会拆参);该常量在包内 internal 不可 import,T-iOS-38 回收时合并。 private static let unreservedCharacters = CharacterSet( charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" ) init(endpoint: HostEndpoint, http: any HTTPTransport) { self.endpoint = endpoint self.http = http } /// `GET /projects/diff?path=&staged=` → 宽容解码后的 `DiffResult`。 /// 空 path 客户端先拒(镜像服务器 400 规则),不产生网络 I/O。 func fetch(path: String, staged: Bool) async throws -> DiffResult { guard !path.isEmpty, let request = buildRequest(path: path, staged: staged) else { throw DiffFetchError.invalidRequest } let (data, response) = try await http.send(request) switch response.statusCode { case Status.ok: guard let result = try? JSONDecoder().decode(DiffResult.self, from: data) else { throw DiffFetchError.invalidResponse } return result case Status.badRequest: throw DiffFetchError.pathInvalid case Status.notFound: throw DiffFetchError.projectNotFound default: throw DiffFetchError.unexpectedStatus(response.statusCode) } } /// Build the RO GET —— 与 APIClient `APIRoute.urlRequest(for:)` 同派生 /// 哲学:baseURL 只取 scheme/host/port,path/query 整体替换,fragment 与 /// 凭据丢弃。**不 stamp Origin**(iff-G,见文件头)。 private func buildRequest(path: String, staged: Bool) -> URLRequest? { guard let encodedPath = path.addingPercentEncoding( withAllowedCharacters: Self.unreservedCharacters ) else { return nil } guard var components = URLComponents( url: endpoint.baseURL, resolvingAgainstBaseURL: true ) else { return nil } components.path = Route.path components.query = nil components.fragment = nil components.user = nil components.password = nil let stagedValue = staged ? Route.stagedOn : Route.stagedOff components.percentEncodedQuery = "\(Route.pathKey)=\(encodedPath)&\(Route.stagedKey)=\(stagedValue)" guard let url = components.url else { return nil } var request = URLRequest(url: url) request.httpMethod = Route.methodGet return request } }