From 039ca6566b28b9ac737c9d922ceb1a9f1a62f6cd Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 14 Oct 2026 12:08:52 +0200 Subject: [PATCH] feat(search): Expose typed collection coverage --- README.md | 13 ++-- .../CLI/cmd/types/TypesSearchCommand.swift | 4 +- .../cmd/types/TypesSearchCommandRunner.swift | 8 +-- Sources/CLI/main/Dependencies.swift | 4 ++ .../DefaultDocumentationSearchRenderer.swift | 15 +++++ .../renderer/DocumentationPresentation.swift | 18 ++++++ .../CLI/renderer/DocumentationPresenter.swift | 20 +++++++ .../DocumentationSearchRenderer.swift | 16 +++++ .../AppleDocumentationRepository+Search.swift | 20 ++++--- .../DocumentationTypeSearchRepository.swift | 29 ++++++++- Sources/CLI/skills/BundledAgentSkills.swift | 6 +- .../AppleDocsCommandIntegrationTests.swift | 14 ++++- .../types/TypesSearchCommandRunnerTests.swift | 22 ++++--- .../cmd/types/TypesSearchCommandTests.swift | 14 +++-- ...aultDocumentationSearchRendererTests.swift | 51 ++++++++++++++++ ...leDocumentationRepositorySearchTests.swift | 6 +- .../DocumentationSearchFailureTests.swift | 59 +++++++++++++++++++ docs/OUTPUT.md | 10 +++- 18 files changed, 286 insertions(+), 43 deletions(-) create mode 100644 Sources/CLI/renderer/DefaultDocumentationSearchRenderer.swift create mode 100644 Sources/CLI/renderer/DocumentationSearchRenderer.swift create mode 100644 Tests/CLITests/renderer/DefaultDocumentationSearchRendererTests.swift create mode 100644 Tests/CLITests/repository/DocumentationSearchFailureTests.swift diff --git a/README.md b/README.md index 523cbec..3fdc8e7 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ apple-docs types search Button --technology SwiftUI Search deliberately does not crawl individual symbol pages, which keeps requests bounded. Collection pages can directly reference some nested members, so those may appear, but search is not an exhaustive nested-member index. A missing search result does not necessarily mean the API is undocumented. If you know the exact type or DocC path, try `types view` directly. -No matches is a successful empty result, rendered as an empty array in JSON. If some collection pages cannot be fetched, available matches are still returned and an incomplete-coverage warning is written to stderr. Cancellation stops the search instead of returning partial results. +No matches is a successful result with an empty `types` array in JSON. Search JSON also includes the resolved `technology` and `coverage`, with `isComplete` and typed `unavailableCollections`. If some collection pages cannot be fetched, available matches are still returned and an incomplete-coverage warning is written to stderr. Unexpected failures and cancellation stop the search instead of returning partial results. ### Type documentation @@ -150,11 +150,12 @@ Agent output uses the same normalized documentation content as human text. Follo The documentation commands accept `--json` for normalized semantic output: -| Command | JSON output | -| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `types view` | An object with `title`, `kind`, `technology`, `path`, `url`, `modules`, `abstract`, `deprecation`, `declarations`, `availability`, `content`, `relationships`, `topics`, and `seeAlso`. | -| `types list` / `types search` | An array with `name`, `kind`, `path`, and `url` fields. | -| `technologies list` | A sorted array of `name` and `identifier` objects. | +| Command | JSON output | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `types view` | An object with `title`, `kind`, `technology`, `path`, `url`, `modules`, `abstract`, `deprecation`, `declarations`, `availability`, `content`, `relationships`, `topics`, and `seeAlso`. | +| `types list` | An array with `name`, `kind`, `path`, and `url` fields. | +| `types search` | An object containing `technology`, a `types` array, and `coverage` with completeness and typed collection failures. | +| `technologies list` | A sorted array of `name` and `identifier` objects. | Add `--agent` to include `navigation` commands for supported destinations. Content blocks retain semantic types and links distinguish documentation, external, and unavailable targets. Availability includes `introducedAt`, `deprecatedAt`, `obsoletedAt`, `isBeta`, and `isUnavailable` when applicable. Inapplicable optional fields are omitted. diff --git a/Sources/CLI/cmd/types/TypesSearchCommand.swift b/Sources/CLI/cmd/types/TypesSearchCommand.swift index 4c21b23..8e355eb 100644 --- a/Sources/CLI/cmd/types/TypesSearchCommand.swift +++ b/Sources/CLI/cmd/types/TypesSearchCommand.swift @@ -5,7 +5,7 @@ struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding { #if DEBUG typealias Deps = any ( TelemetryProvider & TerminalCapabilitiesProvider & DocumentationTypeSearchRepositoryProvider - & DocumentationTypeListRendererProvider & CommandOutputWriterProvider + & DocumentationSearchRendererProvider & CommandOutputWriterProvider ) #else typealias Deps = Dependencies @@ -35,7 +35,7 @@ struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding { deps.telemetry.startCommand(context) let runner = TypesSearchCommandRunner( client: deps.documentationRepository, - renderer: deps.documentationTypeListRenderer(output: output, technology: technology) + renderer: deps.documentationSearchRenderer(output: output) ) let result = try await runner.run( query: query, technology: technology, mode: deps.terminalCapabilities.mode(for: output)) diff --git a/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift b/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift index 2a87699..918f5bc 100644 --- a/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift +++ b/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift @@ -6,11 +6,11 @@ struct TypesSearchCommandRunner: Sendable { } private let client: DocumentationTypeSearchRepository - private let renderer: DocumentationTypeListRenderer + private let renderer: DocumentationSearchRenderer init( client: DocumentationTypeSearchRepository, - renderer: DocumentationTypeListRenderer + renderer: DocumentationSearchRenderer ) { self.client = client self.renderer = renderer @@ -19,9 +19,9 @@ struct TypesSearchCommandRunner: Sendable { func run(query: String, technology: String, mode: OutputMode) async throws -> Result { let result = try await client.searchTypes(query: query, technology: technology) return Result( - output: try renderer.render(result.types), + output: try renderer.render(result), matchCount: result.types.count, - unavailableCollectionCount: result.unavailableCollectionPaths.count + unavailableCollectionCount: result.unavailableCollections.count ) } } diff --git a/Sources/CLI/main/Dependencies.swift b/Sources/CLI/main/Dependencies.swift index 9742fb8..6da0cb1 100644 --- a/Sources/CLI/main/Dependencies.swift +++ b/Sources/CLI/main/Dependencies.swift @@ -71,6 +71,10 @@ struct Dependencies: Sendable { output: output.json ? .json : .table, audience: output.audience, technology: technology) } + func documentationSearchRenderer(output: OutputOptions) -> DefaultDocumentationSearchRenderer { + DefaultDocumentationSearchRenderer(output: output.format, audience: output.audience) + } + func technologyListRenderer(output: OutputOptions) -> DefaultTechnologyListRenderer { DefaultTechnologyListRenderer(output: output.json ? .json : .table, audience: output.audience) } diff --git a/Sources/CLI/renderer/DefaultDocumentationSearchRenderer.swift b/Sources/CLI/renderer/DefaultDocumentationSearchRenderer.swift new file mode 100644 index 0000000..cfd575c --- /dev/null +++ b/Sources/CLI/renderer/DefaultDocumentationSearchRenderer.swift @@ -0,0 +1,15 @@ +struct DefaultDocumentationSearchRenderer: Sendable { + let output: OutputFormat + var audience: OutputAudience = .human + + func render(_ result: DocumentationSearchResult) throws -> String { + let presentation = DocumentationPresenter().search(result, audience: audience) + switch output { + case .json: return try StructuredDocumentationRenderer().render(presentation) + case .text: + return audience == .agent + ? AgentDocumentationRenderer().render(presentation.types, technology: presentation.technology) + : HumanDocumentationRenderer().render(presentation.types) + } + } +} diff --git a/Sources/CLI/renderer/DocumentationPresentation.swift b/Sources/CLI/renderer/DocumentationPresentation.swift index 6d86787..b8b767f 100644 --- a/Sources/CLI/renderer/DocumentationPresentation.swift +++ b/Sources/CLI/renderer/DocumentationPresentation.swift @@ -162,3 +162,21 @@ extension DocumentationBlock: Encodable { } } } + +struct SearchPresentation: Encodable, Sendable { + let technology: String + let types: [SymbolPresentation] + let coverage: SearchCoveragePresentation +} + +struct SearchCoveragePresentation: Encodable, Sendable { + let isComplete: Bool + let unavailableCollections: [CollectionFailurePresentation] +} + +struct CollectionFailurePresentation: Encodable, Sendable { + let path: String + let kind: String + var status: Int? + var code: Int? +} diff --git a/Sources/CLI/renderer/DocumentationPresenter.swift b/Sources/CLI/renderer/DocumentationPresenter.swift index e599235..b6fa5a6 100644 --- a/Sources/CLI/renderer/DocumentationPresenter.swift +++ b/Sources/CLI/renderer/DocumentationPresenter.swift @@ -1,6 +1,26 @@ import Foundation struct DocumentationPresenter: Sendable { + func search(_ result: DocumentationSearchResult, audience: OutputAudience) -> SearchPresentation { + SearchPresentation( + technology: result.technology, + types: symbols(result.types, technology: result.technology, audience: audience), + coverage: SearchCoveragePresentation( + isComplete: result.isComplete, + unavailableCollections: result.unavailableCollections.map { failure in + switch failure.reason { + case .httpStatus(let status): + return CollectionFailurePresentation(path: failure.path, kind: "http", status: status) + case .invalidResponse: + return CollectionFailurePresentation(path: failure.path, kind: "invalidResponse") + case .transport(let code): + return CollectionFailurePresentation(path: failure.path, kind: "transport", code: code) + case .decoding: + return CollectionFailurePresentation(path: failure.path, kind: "decoding") + } + })) + } + func page(_ page: DocumentationPage, audience: OutputAudience) -> PagePresentation { PagePresentation( document: page, audience: audience, diff --git a/Sources/CLI/renderer/DocumentationSearchRenderer.swift b/Sources/CLI/renderer/DocumentationSearchRenderer.swift new file mode 100644 index 0000000..3f6b6b1 --- /dev/null +++ b/Sources/CLI/renderer/DocumentationSearchRenderer.swift @@ -0,0 +1,16 @@ +#if DEBUG + protocol DocumentationSearchRenderer: Sendable { + func render(_ result: DocumentationSearchResult) throws -> String + } + + extension DefaultDocumentationSearchRenderer: DocumentationSearchRenderer {} + + protocol DocumentationSearchRendererProvider { + associatedtype SearchRenderer: DocumentationSearchRenderer + func documentationSearchRenderer(output: OutputOptions) -> SearchRenderer + } + + extension Dependencies: DocumentationSearchRendererProvider {} +#else + typealias DocumentationSearchRenderer = DefaultDocumentationSearchRenderer +#endif diff --git a/Sources/CLI/repository/AppleDocumentationRepository+Search.swift b/Sources/CLI/repository/AppleDocumentationRepository+Search.swift index 2fa0170..466ae73 100644 --- a/Sources/CLI/repository/AppleDocumentationRepository+Search.swift +++ b/Sources/CLI/repository/AppleDocumentationRepository+Search.swift @@ -11,7 +11,7 @@ extension DefaultAppleDocumentationRepository { let rootPath = "/documentation/\(root.slug.lowercased())" logger.debug("Traversing documentation collection groups", metadata: ["path": .string(rootPath)]) var typesByPath: [String: DocumentationType] = [:] - var unavailablePaths: [String] = [] + var unavailableCollections: [DocumentationCollectionFailure] = [] for type in documentationTypes(in: root.page, technology: root.slug) { typesByPath[type.path] = type } @@ -35,7 +35,7 @@ extension DefaultAppleDocumentationRepository { for result in pages { guard case .page(let page) = result else { - if case .unavailable(let path) = result { unavailablePaths.append(path) } + if case .unavailable(let failure) = result { unavailableCollections.append(failure) } continue } for type in documentationTypes(in: page, technology: root.slug) { @@ -50,17 +50,18 @@ extension DefaultAppleDocumentationRepository { try Task.checkCancellation() return searchResult( - query: query, root: root, types: Array(typesByPath.values), unavailablePaths: unavailablePaths) + query: query, root: root, types: Array(typesByPath.values), unavailableCollections: unavailableCollections) } private func searchResult( - query: String, root: DocumentationRoot, types: [DocumentationType], unavailablePaths: [String] + query: String, root: DocumentationRoot, types: [DocumentationType], + unavailableCollections: [DocumentationCollectionFailure] ) -> DocumentationSearchResult { logger.debug( "Documentation search coverage", metadata: [ "apple_docs.technology": .string(root.slug), "candidates": .stringConvertible(types.count), - "unavailable": .stringConvertible(unavailablePaths.count), + "unavailable": .stringConvertible(unavailableCollections.count), ]) let normalizedQuery = query.lowercased() let matches = sortTypes( @@ -82,12 +83,14 @@ extension DefaultAppleDocumentationRepository { metadata: [ "apple_docs.technology": .string(root.name), "matches": .stringConvertible(matches.count), ]) - return DocumentationSearchResult(types: matches, unavailableCollectionPaths: unavailablePaths.sorted()) + return DocumentationSearchResult( + technology: root.slug.lowercased(), types: matches, + unavailableCollections: unavailableCollections.sorted { $0.path < $1.path }) } private enum CollectionResult: Sendable { case page(TechnologyDocumentationPageDTO) - case unavailable(String) + case unavailable(DocumentationCollectionFailure) } private func fetchDocumentationPages( @@ -103,12 +106,13 @@ extension DefaultAppleDocumentationRepository { if error is CancellationError || (error as? URLError)?.code == .cancelled { throw CancellationError() } + guard let reason = DocumentationCollectionFailure.reason(for: error) else { throw error } logger.warning( "Skipping unavailable collection group", metadata: [ "path": .string(path), "error_type": .string(String(reflecting: type(of: error))), ]) - return .unavailable(path) + return .unavailable(DocumentationCollectionFailure(path: path, reason: reason)) } } } diff --git a/Sources/CLI/repository/DocumentationTypeSearchRepository.swift b/Sources/CLI/repository/DocumentationTypeSearchRepository.swift index 90e5145..5bc5d92 100644 --- a/Sources/CLI/repository/DocumentationTypeSearchRepository.swift +++ b/Sources/CLI/repository/DocumentationTypeSearchRepository.swift @@ -5,8 +5,35 @@ import Foundation #endif struct DocumentationSearchResult: Equatable, Sendable { + let technology: String let types: [DocumentationType] - let unavailableCollectionPaths: [String] + let unavailableCollections: [DocumentationCollectionFailure] + + var isComplete: Bool { unavailableCollections.isEmpty } +} + +struct DocumentationCollectionFailure: Equatable, Sendable { + enum Reason: Equatable, Sendable { + case httpStatus(Int) + case invalidResponse + case transport(Int) + case decoding + } + + let path: String + let reason: Reason + + static func reason(for error: any Error) -> Reason? { + if let error = error as? AppleDocumentationClientError { + switch error { + case .httpStatus(let status): return .httpStatus(status) + case .invalidResponse: return .invalidResponse + } + } + if let error = error as? URLError { return .transport(error.code.rawValue) } + if error is DecodingError { return .decoding } + return nil + } } #if DEBUG diff --git a/Sources/CLI/skills/BundledAgentSkills.swift b/Sources/CLI/skills/BundledAgentSkills.swift index 8255ea0..7a7b27f 100644 --- a/Sources/CLI/skills/BundledAgentSkills.swift +++ b/Sources/CLI/skills/BundledAgentSkills.swift @@ -90,10 +90,10 @@ enum BundledAgentSkills { apple-docs types view URLSession --technology Foundation --agent --json ``` - Technology, list, and search JSON are arrays. Page JSON is a normalized semantic object, not raw DocC bytes. + Catalog JSON is arrays. Search JSON contains `technology`, `types`, and `coverage`. Check coverage before + interpreting no matches: `isComplete` and `unavailableCollections`. Page JSON is semantic, not raw DocC. Inspect `title`, `declarations`, `availability`, `content`, `relationships`, `topics`, and `seeAlso`. - Agent output adds `navigation` commands where a destination can be represented safely on the CLI. - Follow validated targets in grouped references. A missing field is not a guarantee. + Agent `navigation` commands and grouped references retain validated targets. A missing field is not a guarantee. ## Availability and examples diff --git a/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift index fadac2d..2ea9510 100644 --- a/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift +++ b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift @@ -127,9 +127,11 @@ struct AppleDocsCommandIntegrationTests { // -- Act -- let output = try runAppleDocs(arguments) - let types = try JSONDecoder().decode([ListedType].self, from: Data(output.utf8)) + let result = try JSONDecoder().decode(SearchResult.self, from: Data(output.utf8)) + let types = result.types // -- Assert -- + #expect(result.technology == "swiftui") #expect( types.contains( ListedType( @@ -205,3 +207,13 @@ private struct Technology: Decodable, Equatable { let identifier: String let name: String } + +private struct SearchResult: Decodable { + let technology: String + let types: [ListedType] + let coverage: Coverage + + struct Coverage: Decodable { + let isComplete: Bool + } +} diff --git a/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift b/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift index 2094935..9126f0a 100644 --- a/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift +++ b/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import CLI @@ -30,19 +31,22 @@ struct TypesSearchCommandRunnerTests { #expect(result.unavailableCollectionCount == 1) } - @Test("empty searches are successful JSON arrays") + @Test("empty searches are successful structured results with coverage") func rendersEmptySearch() async throws { // -- Arrange -- let runner = TypesSearchCommandRunner( client: RequestedTypeSearchClient(types: []), - renderer: DefaultDocumentationTypeListRenderer(output: .json)) + renderer: DefaultDocumentationSearchRenderer(output: .json)) // -- Act -- let result = try await runner.run( query: "Button", technology: "SwiftUI", mode: .oneShot(audience: .human, format: .json)) // -- Assert -- - #expect(result.output == "[\n\n]") + let json = try #require(JSONSerialization.jsonObject(with: Data(result.output.utf8)) as? [String: Any]) + #expect((json["types"] as? [Any])?.isEmpty == true) + let coverage = try #require(json["coverage"] as? [String: Any]) + #expect(coverage["isComplete"] as? Bool == false) #expect(result.matchCount == 0) #expect(result.unavailableCollectionCount == 1) } @@ -55,15 +59,19 @@ private struct RequestedTypeSearchClient: DocumentationTypeSearchRepository { guard query == "Button", technology == "SwiftUI" else { throw TypesSearchRunnerTestError.unexpectedRequest } - return DocumentationSearchResult(types: types, unavailableCollectionPaths: ["/documentation/swiftui/styles"]) + return DocumentationSearchResult( + technology: "swiftui", types: types, + unavailableCollections: [ + DocumentationCollectionFailure(path: "/documentation/swiftui/styles", reason: .httpStatus(404)) + ]) } } -private struct SearchTypesRenderer: DocumentationTypeListRenderer { +private struct SearchTypesRenderer: DocumentationSearchRenderer { let expectedTypes: [DocumentationType] - func render(_ types: [DocumentationType]) throws -> String { - guard types == expectedTypes else { + func render(_ result: DocumentationSearchResult) throws -> String { + guard result.types == expectedTypes else { throw TypesSearchRunnerTestError.unexpectedTypes } return "rendered matches" diff --git a/Tests/CLITests/cmd/types/TypesSearchCommandTests.swift b/Tests/CLITests/cmd/types/TypesSearchCommandTests.swift index 47a9312..cbe8ef1 100644 --- a/Tests/CLITests/cmd/types/TypesSearchCommandTests.swift +++ b/Tests/CLITests/cmd/types/TypesSearchCommandTests.swift @@ -25,7 +25,7 @@ struct TypesSearchCommandTests { } private typealias SearchProviders = TelemetryProvider & TerminalCapabilitiesProvider - & DocumentationTypeSearchRepositoryProvider & DocumentationTypeListRendererProvider & CommandOutputWriterProvider + & DocumentationTypeSearchRepositoryProvider & DocumentationSearchRendererProvider & CommandOutputWriterProvider private struct SearchDependencies: SearchProviders { let telemetry = RecordingCommandTelemetry() @@ -34,7 +34,7 @@ private struct SearchDependencies: SearchProviders { let output = RecordingCommandOutputWriter() var commandOutputWriter: RecordingCommandOutputWriter { output } - func documentationTypeListRenderer(output: OutputOptions, technology: String) -> SearchTestRenderer { + func documentationSearchRenderer(output: OutputOptions) -> SearchTestRenderer { SearchTestRenderer() } } @@ -43,10 +43,14 @@ private struct SearchTestClient: DocumentationTypeSearchRepository { func searchTypes(query: String, technology: String) async throws -> DocumentationSearchResult { #expect(query == "Button") #expect(technology == "SwiftUI") - return DocumentationSearchResult(types: [], unavailableCollectionPaths: ["/documentation/swiftui/controls"]) + return DocumentationSearchResult( + technology: "swiftui", types: [], + unavailableCollections: [ + DocumentationCollectionFailure(path: "/documentation/swiftui/controls", reason: .httpStatus(404)) + ]) } } -private struct SearchTestRenderer: DocumentationTypeListRenderer { - func render(_ types: [DocumentationType]) -> String { "[]" } +private struct SearchTestRenderer: DocumentationSearchRenderer { + func render(_ result: DocumentationSearchResult) -> String { "[]" } } diff --git a/Tests/CLITests/renderer/DefaultDocumentationSearchRendererTests.swift b/Tests/CLITests/renderer/DefaultDocumentationSearchRendererTests.swift new file mode 100644 index 0000000..83cf8e8 --- /dev/null +++ b/Tests/CLITests/renderer/DefaultDocumentationSearchRendererTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Documentation search rendering") +struct DefaultDocumentationSearchRendererTests { + @Test("JSON exposes partial coverage even when there are no matches", arguments: [OutputAudience.human, .agent]) + func rendersIncompleteSearch(audience: OutputAudience) throws { + // -- Arrange -- + let result = DocumentationSearchResult( + technology: "swiftui", types: [], + unavailableCollections: [ + .init(path: "/documentation/swiftui/controls", reason: .httpStatus(503)), + .init(path: "/documentation/swiftui/layout", reason: .decoding), + ]) + let renderer = DefaultDocumentationSearchRenderer(output: .json, audience: audience) + + // -- Act -- + let output = try renderer.render(result) + let json = try #require(JSONSerialization.jsonObject(with: Data(output.utf8)) as? [String: Any]) + let coverage = try #require(json["coverage"] as? [String: Any]) + let unavailable = try #require(coverage["unavailableCollections"] as? [[String: Any]]) + + // -- Assert -- + #expect(json["technology"] as? String == "swiftui") + #expect((json["types"] as? [Any])?.isEmpty == true) + #expect(coverage["isComplete"] as? Bool == false) + #expect(unavailable.count == 2) + #expect(unavailable[0]["path"] as? String == "/documentation/swiftui/controls") + #expect(unavailable[0]["kind"] as? String == "http") + #expect(unavailable[0]["status"] as? Int == 503) + #expect(unavailable[1]["kind"] as? String == "decoding") + } + + @Test("complete empty searches retain explicit coverage") + func rendersCompleteEmptySearch() throws { + // -- Arrange -- + let result = DocumentationSearchResult(technology: "swift", types: [], unavailableCollections: []) + + // -- Act -- + let output = try DefaultDocumentationSearchRenderer(output: .json).render(result) + let json = try #require(JSONSerialization.jsonObject(with: Data(output.utf8)) as? [String: Any]) + let coverage = try #require(json["coverage"] as? [String: Any]) + + // -- Assert -- + #expect(coverage["isComplete"] as? Bool == true) + #expect((coverage["unavailableCollections"] as? [Any])?.isEmpty == true) + #expect((json["types"] as? [Any])?.isEmpty == true) + } +} diff --git a/Tests/CLITests/repository/AppleDocumentationRepositorySearchTests.swift b/Tests/CLITests/repository/AppleDocumentationRepositorySearchTests.swift index 5b39bf9..0799e0d 100644 --- a/Tests/CLITests/repository/AppleDocumentationRepositorySearchTests.swift +++ b/Tests/CLITests/repository/AppleDocumentationRepositorySearchTests.swift @@ -25,7 +25,7 @@ struct AppleDocumentationRepositorySearchTests { // -- Assert -- #expect(result.types.map(\.name) == ["ButtonStyle"]) - #expect(result.unavailableCollectionPaths == ["/documentation/swiftui/controls"]) + #expect(result.unavailableCollections.map(\.path) == ["/documentation/swiftui/controls"]) #expect(Set(await transport.requestedURLs) == Set([rootURL, controlsURL, stylesURL])) let coverage = try #require(recorder.events.first { $0.message.description == "Documentation search coverage" }) #expect(coverage.level == .debug) @@ -47,7 +47,7 @@ struct AppleDocumentationRepositorySearchTests { // -- Assert -- #expect(result.types.isEmpty) - #expect(result.unavailableCollectionPaths.isEmpty) + #expect(result.unavailableCollections.map(\.path).isEmpty) #expect(await transport.requestedURLs == [rootURL]) } @@ -210,7 +210,7 @@ struct AppleDocumentationRepositorySearchTests { // -- Assert -- #expect(result.types.isEmpty) - #expect(result.unavailableCollectionPaths.isEmpty) + #expect(result.unavailableCollections.map(\.path).isEmpty) #expect(await transport.requestedURLs.count == 3) } diff --git a/Tests/CLITests/repository/DocumentationSearchFailureTests.swift b/Tests/CLITests/repository/DocumentationSearchFailureTests.swift new file mode 100644 index 0000000..0579369 --- /dev/null +++ b/Tests/CLITests/repository/DocumentationSearchFailureTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Logging +import Testing + +@testable import CLI + +@Suite("Documentation search failure classification") +struct DocumentationSearchFailureTests { + @Test("retains typed HTTP collection failures", arguments: [404, 429, 503]) + func retainsHTTPFailures(status: Int) async throws { + // -- Arrange -- + let rootURL = try #require(URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftui.json")) + let collectionURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftui/controls.json")) + let repository = makeDocumentationRepository( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, + dependencies: HTTPTestTransport(responses: [ + rootURL: .http(data: rootSearchPage), collectionURL: .http(statusCode: status, data: Data()), + ])) + + // -- Act -- + let result = try await repository.searchTypes(query: "Button", technology: "SwiftUI") + + // -- Assert -- + #expect(result.technology == "swiftui") + #expect( + result.unavailableCollections == [ + DocumentationCollectionFailure(path: "/documentation/swiftui/controls", reason: .httpStatus(status)) + ]) + #expect(!result.isComplete) + } + + @Test("propagates unexpected collection errors instead of misreporting incomplete coverage") + func propagatesUnexpectedFailures() async throws { + // -- Arrange -- + let rootURL = try #require(URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftui.json")) + let collectionURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftui/controls.json")) + let repository = makeDocumentationRepository( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, + dependencies: HTTPTestTransport(responses: [ + rootURL: .http(data: rootSearchPage), collectionURL: .failure(UnexpectedCollectionFailure()), + ])) + + // -- Act -- + let search = { try await repository.searchTypes(query: "Button", technology: "SwiftUI") } + + // -- Assert -- + await #expect(throws: UnexpectedCollectionFailure.self) { try await search() } + } + +} + +private struct UnexpectedCollectionFailure: Error {} + +private let rootSearchPage = Data( + #""" + {"references":{"controls":{"role":"collectionGroup","url":"/documentation/swiftui/controls"}}} + """#.utf8) diff --git a/docs/OUTPUT.md b/docs/OUTPUT.md index 88e5ce2..17f69d7 100644 --- a/docs/OUTPUT.md +++ b/docs/OUTPUT.md @@ -34,7 +34,7 @@ Named CLI input interprets dots as hierarchy separators and lowercases paths. No ## JSON result shapes -There is no versioned envelope. Page output is an object. Symbol and technology catalogs are top-level arrays. +There is no versioned envelope. Page and search output are objects. Symbol and technology catalogs are top-level arrays. ### Pages @@ -77,11 +77,14 @@ Targets use a `type` of `documentation`, `external`, or `unavailable`. Documenta ### Discovery results -- `types list` and `types search`: arrays of objects with `name`, `kind`, technology-relative `path`, and `url`. +- `types list`: an array of objects with `name`, `kind`, technology-relative `path`, and `url`. +- `types search`: an object with resolved `technology`, a `types` array with those same symbol fields, and `coverage`. - `technologies list`: a case-insensitively sorted array of objects with `name` and `identifier`. - Agent variants additionally include `navigation` where supported. Navigation contains `technology`, a technology-relative `path` for document destinations, and `command`. Technology-root navigation omits `path`. -No search matches is a successful empty array, not an error. Partial results remain usable but emit an incomplete-coverage warning to stderr. Human text reports no matches explicitly. Cancellation stops the search rather than returning partial results. +No search matches is a successful result with an empty `types` array, not an error. Search coverage contains `isComplete` and `unavailableCollections`. Each unavailable collection contains `path` and `kind`: `http` with `status`, `transport` with a URL error `code`, `invalidResponse`, or `decoding`. + +Completeness refers to the reachable curated collection graph, not an exhaustive index of every symbol. Partial results remain usable and also emit a warning to stderr. Unexpected failures abort the search. Human text reports no matches explicitly. Cancellation stops the search rather than returning partial results. ## Compatibility with older output @@ -91,6 +94,7 @@ The current output contracts differ from the earlier raw-DocC interface: - Use both `--agent --json` for structured agent output. - `types view --json` emits normalized semantic content, not unchanged upstream DocC bytes. - There is no replacement raw-DocC export flag. +- Search JSON uses an object rather than a top-level array. Read matches from `.types` and check `.coverage.isComplete`. For example, title extraction uses `.title`, not `.metadata.title`: