diff --git a/README.md b/README.md index 54762f9..301834e 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,23 @@ The terminal output includes available information such as: - Canonical Apple Developer URL Every invocation requires `--technology`. The CLI does not persist a selected framework or other session state. +Nested symbols accept either dotted Swift spelling or slash-separated DocC paths: + +```bash +apple-docs types view URLSession.AsyncBytes --technology Foundation +apple-docs types view URLSession/AsyncBytes --technology Foundation +``` + +## Type discovery + +List the API symbols referenced directly by a technology's root DocC page: + +```bash +apple-docs types list --technology MetricKit +apple-docs types list --technology MetricKit --json +``` + +Each result includes the symbol name, kind, command-ready DocC path, and canonical Apple Developer URL. Apple's root pages are curated, so large frameworks may link to collection pages instead of listing every API directly. ## Technologies diff --git a/Sources/CLI/client/AppleDocumentationClient+DTO.swift b/Sources/CLI/client/AppleDocumentationClient+DTO.swift index 2d89c0e..58574a1 100644 --- a/Sources/CLI/client/AppleDocumentationClient+DTO.swift +++ b/Sources/CLI/client/AppleDocumentationClient+DTO.swift @@ -15,6 +15,7 @@ struct DocumentationVariantDTO: Decodable, Sendable { } struct DocumentationTextDTO: Decodable, Sendable { + let code: String? let identifier: String? let text: String? } @@ -25,7 +26,20 @@ struct DocumentationBlockDTO: Decodable, Sendable { struct DocumentationReferenceDTO: Decodable, Sendable { let abstract: [DocumentationTextDTO]? + let fragments: [DocumentationFragmentDTO]? + let kind: String? + let role: String? let title: String? + let url: String? +} + +struct DocumentationFragmentDTO: Decodable, Sendable { + let kind: String + let text: String +} + +struct TechnologyDocumentationPageDTO: Decodable, Sendable { + let references: [String: DocumentationReferenceDTO] } struct DocumentationReferenceSectionDTO: Decodable, Sendable { diff --git a/Sources/CLI/client/AppleDocumentationClient.swift b/Sources/CLI/client/AppleDocumentationClient.swift index 5570c3f..eef904f 100644 --- a/Sources/CLI/client/AppleDocumentationClient.swift +++ b/Sources/CLI/client/AppleDocumentationClient.swift @@ -9,9 +9,26 @@ import Foundation #endif struct DefaultAppleDocumentationClient: Sendable { - enum Error: Swift.Error, Equatable, LocalizedError { + enum Error: Swift.Error, Equatable, LocalizedError, ExpectedCommandError { case httpStatus(Int) case invalidResponse + case technologyNotFound(String) + case typeNotFound( + name: String, + technology: String, + suggestion: DocumentationType?, + technologyURL: String + ) + case unsupportedTechnology(name: String, url: String) + + var isExpected: Bool { + switch self { + case .technologyNotFound, .typeNotFound, .unsupportedTechnology: + return true + case .httpStatus, .invalidResponse: + return false + } + } var errorDescription: String? { switch self { @@ -19,10 +36,49 @@ struct DefaultAppleDocumentationClient TypeDocumentationDocument { - let url = baseURL.appending(component: "documentation") - .appending(component: technology.lowercased()) - .appending(component: name.lowercased()) - .appendingPathExtension("json") - let (data, response) = try await dependencies.data(from: url) + do { + return TypeDocumentationDocument( + data: try await fetchData(from: typeURL(name: name, technology: technology)) + ) + } catch Error.httpStatus(404) { + let resolved = try await resolveTechnology(named: technology) + guard let slug = resolved.documentationSlug else { + throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url) + } - guard let httpResponse = response as? HTTPURLResponse else { - throw Self.Error.invalidResponse - } - guard (200..<300).contains(httpResponse.statusCode) else { - throw Self.Error.httpStatus(httpResponse.statusCode) + // Display names do not always match DocC path components, such as Apple CryptoKit. + if slug.caseInsensitiveCompare(technology) != .orderedSame { + do { + return TypeDocumentationDocument( + data: try await fetchData(from: typeURL(name: name, technology: slug)) + ) + } catch Error.httpStatus(404) { + // Continue with the canonical root so the error can offer useful discovery links. + } + } + + let types = try await fetchTypesDirect(technology: slug) + let normalizedName = normalizedSymbolName(name) + let suggestion = types.first { + normalizedSymbolName($0.name) == normalizedName + } + throw Error.typeNotFound( + name: name, + technology: resolved.name, + suggestion: suggestion, + technologyURL: resolved.url + ) } + } - let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) - return TypeDocumentationDocument(data: data, page: page) + func fetchTypes(technology: String) async throws -> [DocumentationType] { + do { + return try await fetchTypesDirect(technology: technology) + } catch Error.httpStatus(404) { + let resolved = try await resolveTechnology(named: technology) + guard let slug = resolved.documentationSlug else { + throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url) + } + guard slug.caseInsensitiveCompare(technology) != .orderedSame else { + // Retrying the same case-insensitive path cannot produce a different result. + throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url) + } + do { + return try await fetchTypesDirect(technology: slug) + } catch Error.httpStatus(404) { + throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url) + } + } } func fetchTechnologies() async throws -> [Technology] { let url = baseURL.appending(component: "documentation") .appending(component: "technologies") .appendingPathExtension("json") - let (data, response) = try await dependencies.data(from: url) + let data = try await fetchData(from: url) + let page = try JSONDecoder().decode(TechnologyCatalogPageDTO.self, from: data) + return page.sections.flatMap(\.groups).flatMap(\.technologies).map { + Technology(name: $0.title, identifier: $0.destination.identifier) + } + } + private func fetchTypesDirect(technology: String) async throws -> [DocumentationType] { + let url = baseURL.appending(component: "documentation") + .appending(component: technology.lowercased()) + .appendingPathExtension("json") + let data = try await fetchData(from: url) + let page = try JSONDecoder().decode(TechnologyDocumentationPageDTO.self, from: data) + let pathPrefix = "/documentation/\(technology.lowercased())/" + + // Root pages also reference articles and neighboring frameworks. The symbol role and path + // prefix keep this command faithful to Apple's direct API listing for the requested technology. + return page.references.values.compactMap { reference in + guard + reference.kind == "symbol", + reference.role == "symbol", + let name = reference.title, + let referencePath = reference.url, + referencePath.lowercased().hasPrefix(pathPrefix) + else { + return nil + } + + let path = String(referencePath.dropFirst(pathPrefix.count)) + let kind = reference.fragments?.first { $0.kind == "keyword" }?.text ?? "symbol" + return DocumentationType( + name: name, + kind: kind, + path: path, + url: "https://developer.apple.com\(referencePath)" + ) + }.sorted { + let comparison = $0.name.compare($1.name, options: .caseInsensitive) + return comparison == .orderedSame ? $0.path < $1.path : comparison == .orderedAscending + } + } + + private func fetchData(from url: URL) async throws -> Data { + let (data, response) = try await dependencies.data(from: url) guard let httpResponse = response as? HTTPURLResponse else { - throw Self.Error.invalidResponse + throw Error.invalidResponse } guard (200..<300).contains(httpResponse.statusCode) else { - throw Self.Error.httpStatus(httpResponse.statusCode) + throw Error.httpStatus(httpResponse.statusCode) } + return data + } - let page = try JSONDecoder().decode(TechnologyCatalogPageDTO.self, from: data) - return page.sections.flatMap(\.groups).flatMap(\.technologies).map { - Technology(name: $0.title, identifier: $0.destination.identifier) + private func typeURL(name: String, technology: String) -> URL { + var url = baseURL.appending(component: "documentation") + .appending(component: technology.lowercased()) + // DocC uses path components for nested symbols while Swift spelling uses dots. + for component in name.replacingOccurrences(of: ".", with: "/").split(separator: "/") { + url.append(component: component.lowercased()) } + url.appendPathExtension("json") + return url + } + + private func resolveTechnology(named requestedName: String) async throws -> ResolvedTechnology { + let technologies = try await fetchTechnologies() + guard + let technology = technologies.first(where: { + $0.name.caseInsensitiveCompare(requestedName) == .orderedSame + || documentationSlug(from: $0.identifier)?.caseInsensitiveCompare(requestedName) + == .orderedSame + }) + else { + throw Error.technologyNotFound(requestedName) + } + + return ResolvedTechnology( + name: technology.name, + documentationSlug: documentationSlug(from: technology.identifier), + url: publicURL(from: technology.identifier) + ) + } + + private func documentationSlug(from identifier: String) -> String? { + let marker = "/documentation/" + guard let range = identifier.range(of: marker) else { + return nil + } + let remainder = identifier[range.upperBound...] + guard !remainder.contains("/") else { + return nil + } + return String(remainder) + } + + private func publicURL(from identifier: String) -> String { + guard identifier.hasPrefix("doc://"), let pathStart = identifier.dropFirst(6).firstIndex(of: "/") else { + return identifier + } + return "https://developer.apple.com\(identifier[pathStart...].lowercased())" + } + + private func normalizedSymbolName(_ name: String) -> String { + name.lowercased().filter { $0.isLetter || $0.isNumber } } } diff --git a/Sources/CLI/client/DocumentationTypeCatalogClient.swift b/Sources/CLI/client/DocumentationTypeCatalogClient.swift new file mode 100644 index 0000000..15ac033 --- /dev/null +++ b/Sources/CLI/client/DocumentationTypeCatalogClient.swift @@ -0,0 +1,11 @@ +import Foundation + +#if DEBUG + protocol DocumentationTypeCatalogClient: Sendable { + func fetchTypes(technology: String) async throws -> [DocumentationType] + } + + extension DefaultAppleDocumentationClient: DocumentationTypeCatalogClient {} +#else + typealias DocumentationTypeCatalogClient = DefaultAppleDocumentationClient +#endif diff --git a/Sources/CLI/client/TypeDocumentationDocument.swift b/Sources/CLI/client/TypeDocumentationDocument.swift index acd0eb1..68eab9f 100644 --- a/Sources/CLI/client/TypeDocumentationDocument.swift +++ b/Sources/CLI/client/TypeDocumentationDocument.swift @@ -2,5 +2,4 @@ import Foundation struct TypeDocumentationDocument: Sendable { let data: Data - let page: TypeDocumentationPageDTO } diff --git a/Sources/CLI/cmd/types/TypesCommand.swift b/Sources/CLI/cmd/types/TypesCommand.swift index 2530475..24ef78a 100644 --- a/Sources/CLI/cmd/types/TypesCommand.swift +++ b/Sources/CLI/cmd/types/TypesCommand.swift @@ -4,6 +4,6 @@ struct TypesCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "types", abstract: "Work with Apple documentation types.", - subcommands: [TypesViewCommand.self] + subcommands: [TypesListCommand.self, TypesViewCommand.self] ) } diff --git a/Sources/CLI/cmd/types/TypesListCommand.swift b/Sources/CLI/cmd/types/TypesListCommand.swift new file mode 100644 index 0000000..cec5f0a --- /dev/null +++ b/Sources/CLI/cmd/types/TypesListCommand.swift @@ -0,0 +1,67 @@ +import ArgumentParser +import Logging +@preconcurrency import SentrySwift + +struct TypesListCommand: AsyncParsableCommand { + private static let logger = Logger( + label: "com.techprimate.apple-docs.types-list" + ) + + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List types in an Apple documentation technology." + ) + + @Option(help: "The framework or technology whose types to list.") + var technology: String + + @Flag(help: "Output a JSON array of types.") + var json = false + + mutating func run() async throws { + let context = SentryCommandContext.typesList( + technology: technology, + json: json + ) + if SentrySDK.isEnabled { + let transaction = SentrySDK.startTransaction( + name: context.transactionName, + operation: "console.command", + bindToScope: true + ) + for (key, value) in context.attributes { + transaction.setData(value: value, key: key) + } + SentrySDK.configureScope { scope in + scope.setContext(value: context.attributes, key: "cli") + } + let breadcrumb = Breadcrumb( + level: .info, + category: SentryConfiguration.breadcrumbCategory + ) + breadcrumb.type = "user" + breadcrumb.message = "CLI command invoked" + for (key, value) in context.attributes { + breadcrumb.setData(value: value, key: key) + } + SentrySDK.addBreadcrumb(breadcrumb) + Self.logger.info( + "CLI command started", + metadata: context.logMetadata + ) + } + + let result = try await TypesListCommandRunner( + client: Dependencies.documentationClient, + renderer: Dependencies.documentationTypeListRenderer(json: json) + ).run(technology: technology) + if SentrySDK.isEnabled { + SentrySDK.metrics.gauge( + key: "apple_docs.type.catalog.count", + value: Double(result.typeCount), + attributes: context.metricAttributes + ) + } + print(result.output) + } +} diff --git a/Sources/CLI/cmd/types/TypesListCommandRunner.swift b/Sources/CLI/cmd/types/TypesListCommandRunner.swift new file mode 100644 index 0000000..8a4fbde --- /dev/null +++ b/Sources/CLI/cmd/types/TypesListCommandRunner.swift @@ -0,0 +1,25 @@ +struct TypesListCommandRunner: Sendable { + struct Result: Sendable { + let output: String + let typeCount: Int + } + + private let client: DocumentationTypeCatalogClient + private let renderer: DocumentationTypeListRenderer + + init( + client: DocumentationTypeCatalogClient, + renderer: DocumentationTypeListRenderer + ) { + self.client = client + self.renderer = renderer + } + + func run(technology: String) async throws -> Result { + let types = try await client.fetchTypes(technology: technology) + return Result( + output: try renderer.render(types), + typeCount: types.count + ) + } +} diff --git a/Sources/CLI/cmd/types/TypesViewCommandRunner.swift b/Sources/CLI/cmd/types/TypesViewCommandRunner.swift index 8f3b40b..ceffe68 100644 --- a/Sources/CLI/cmd/types/TypesViewCommandRunner.swift +++ b/Sources/CLI/cmd/types/TypesViewCommandRunner.swift @@ -21,7 +21,7 @@ struct TypesViewCommandRunner: Sendable { technology: technology ) return Result( - output: renderer.render(document), + output: try renderer.render(document), responseByteCount: document.data.count ) } diff --git a/Sources/CLI/main/AppleDocs.swift b/Sources/CLI/main/AppleDocs.swift index b3319bd..65cf406 100644 --- a/Sources/CLI/main/AppleDocs.swift +++ b/Sources/CLI/main/AppleDocs.swift @@ -41,7 +41,8 @@ enum AppleDocs { } } catch { if telemetryEnabled, let span = SentrySDK.span { - let expected = error is ValidationError + // Lookup misses are actionable CLI outcomes, not application reliability failures. + let expected = error is ValidationError || SentryConfiguration.isExpected(error: error) span.status = expected ? .invalidArgument : .internalError if expected { Self.logger.info("CLI command rejected") diff --git a/Sources/CLI/main/Dependencies.swift b/Sources/CLI/main/Dependencies.swift index d8e3cde..af8ea4c 100644 --- a/Sources/CLI/main/Dependencies.swift +++ b/Sources/CLI/main/Dependencies.swift @@ -14,6 +14,14 @@ enum Dependencies { ) } + static func documentationTypeListRenderer( + json: Bool + ) -> DefaultDocumentationTypeListRenderer { + DefaultDocumentationTypeListRenderer( + output: json ? .json : .table + ) + } + static func technologyListRenderer( json: Bool ) -> DefaultTechnologyListRenderer { diff --git a/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift b/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift new file mode 100644 index 0000000..0cc8b6b --- /dev/null +++ b/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift @@ -0,0 +1,45 @@ +import Foundation + +struct DefaultDocumentationTypeListRenderer: Sendable { + enum Output: Sendable { + case table + case json + } + + private let output: Output + + init(output: Output) { + self.output = output + } + + func render(_ types: [DocumentationType]) throws -> String { + switch output { + case .table: + return renderTable(types) + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + // JSONEncoder produces valid UTF-8, so preserve a non-optional rendering contract. + // swiftlint:disable:next optional_data_string_conversion + return String(decoding: try encoder.encode(types), as: UTF8.self) + } + } + + private func renderTable(_ types: [DocumentationType]) -> String { + let nameWidth = max("SYMBOL".count, types.map(\.name.count).max() ?? 0) + let kindWidth = max("KIND".count, types.map(\.kind.count).max() ?? 0) + let pathWidth = max("PATH".count, types.map(\.path.count).max() ?? 0) + + let heading = + "SYMBOL".padding(toLength: nameWidth, withPad: " ", startingAt: 0) + " " + + "KIND".padding(toLength: kindWidth, withPad: " ", startingAt: 0) + " " + + "PATH".padding(toLength: pathWidth, withPad: " ", startingAt: 0) + " URL" + let rows = types.map { type in + type.name.padding(toLength: nameWidth, withPad: " ", startingAt: 0) + " " + + type.kind.padding(toLength: kindWidth, withPad: " ", startingAt: 0) + " " + + type.path.padding(toLength: pathWidth, withPad: " ", startingAt: 0) + " " + + type.url + } + return ([heading] + rows).joined(separator: "\n") + } +} diff --git a/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift b/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift index 8f5e058..71e20f6 100644 --- a/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift +++ b/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift @@ -1,3 +1,5 @@ +import Foundation + struct DefaultTypeDocumentationRenderer: Sendable { enum Output: Sendable { case text @@ -10,10 +12,11 @@ struct DefaultTypeDocumentationRenderer: Sendable { self.output = output } - func render(_ document: TypeDocumentationDocument) -> String { + func render(_ document: TypeDocumentationDocument) throws -> String { switch output { case .text: - return TextTypeDocumentationRenderer().render(document.page) + let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: document.data) + return TextTypeDocumentationRenderer().render(page) case .json: return RawJSONTypeDocumentationRenderer().render(document) } diff --git a/Sources/CLI/renderer/DocumentationTypeListRenderer.swift b/Sources/CLI/renderer/DocumentationTypeListRenderer.swift new file mode 100644 index 0000000..1899c1b --- /dev/null +++ b/Sources/CLI/renderer/DocumentationTypeListRenderer.swift @@ -0,0 +1,9 @@ +#if DEBUG + protocol DocumentationTypeListRenderer: Sendable { + func render(_ types: [DocumentationType]) throws -> String + } + + extension DefaultDocumentationTypeListRenderer: DocumentationTypeListRenderer {} +#else + typealias DocumentationTypeListRenderer = DefaultDocumentationTypeListRenderer +#endif diff --git a/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift b/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift index e6e887e..4753560 100644 --- a/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift +++ b/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift @@ -77,10 +77,16 @@ struct TextTypeDocumentationRenderer: Sendable { } var item = " \(title)" - let abstract = reference.abstract?.compactMap(\.text).joined() ?? "" + let abstract = + reference.abstract.map { + inlineText($0, references: references) + } ?? "" if includesAbstract && !abstract.isEmpty { item += " — \(abstract)" } + if let url = reference.url { + item += "\n \(documentationURL(for: url))" + } return item } @@ -105,6 +111,13 @@ struct TextTypeDocumentationRenderer: Sendable { } } + private func documentationURL(for path: String) -> String { + if path.hasPrefix("http://") || path.hasPrefix("https://") { + return path + } + return "https://developer.apple.com\(path)" + } + private func inlineText( _ content: [DocumentationTextDTO], references: [String: DocumentationReferenceDTO] @@ -116,7 +129,8 @@ struct TextTypeDocumentationRenderer: Sendable { if let identifier = item.identifier { return references[identifier]?.title ?? identifier } - return "" + // DocC encodes inline symbol spelling such as AppIntent as codeVoice, not text. + return item.code ?? "" }.joined() } } diff --git a/Sources/CLI/renderer/TypeDocumentationRenderer.swift b/Sources/CLI/renderer/TypeDocumentationRenderer.swift index 65d0cb8..7015210 100644 --- a/Sources/CLI/renderer/TypeDocumentationRenderer.swift +++ b/Sources/CLI/renderer/TypeDocumentationRenderer.swift @@ -1,6 +1,6 @@ #if DEBUG protocol TypeDocumentationRenderer: Sendable { - func render(_ document: TypeDocumentationDocument) -> String + func render(_ document: TypeDocumentationDocument) throws -> String } extension DefaultTypeDocumentationRenderer: TypeDocumentationRenderer {} diff --git a/Sources/CLI/skills/BundledAgentSkills.swift b/Sources/CLI/skills/BundledAgentSkills.swift index 2a01799..e735ebb 100644 --- a/Sources/CLI/skills/BundledAgentSkills.swift +++ b/Sources/CLI/skills/BundledAgentSkills.swift @@ -38,7 +38,23 @@ enum BundledAgentSkills { ``` The text output includes the type summary, declaration, availability, inheritance, conformances, documented - members, related APIs, and canonical Apple Developer URL. + members, related APIs, and canonical Apple Developer URL. Use a returned slash-separated path, or a dotted + Swift type name, to retrieve nested documentation: + + ```bash + apple-docs types view URLSession.AsyncBytes --technology Foundation + ``` + + ## Discover root types + + List the symbols referenced directly by a technology's root DocC page: + + ```bash + apple-docs types list --technology MetricKit + apple-docs types list --technology MetricKit --json + ``` + + Apple's root pages are curated and may link to collection pages instead of listing every API directly. ## Retrieve raw DocC JSON diff --git a/Sources/CLI/telemetry/SentryCommandContext.swift b/Sources/CLI/telemetry/SentryCommandContext.swift index 067dedd..781818d 100644 --- a/Sources/CLI/telemetry/SentryCommandContext.swift +++ b/Sources/CLI/telemetry/SentryCommandContext.swift @@ -29,6 +29,18 @@ struct SentryCommandContext: Equatable, Sendable { ) } + static func typesList( + technology: String, + json: Bool + ) -> SentryCommandContext { + SentryCommandContext( + command: "types.list", + outputJSON: json, + technology: technology, + typeName: nil + ) + } + static func typesView( name: String, technology: String, diff --git a/Sources/CLI/telemetry/SentryConfiguration.swift b/Sources/CLI/telemetry/SentryConfiguration.swift index 3b1365a..ddc0f43 100644 --- a/Sources/CLI/telemetry/SentryConfiguration.swift +++ b/Sources/CLI/telemetry/SentryConfiguration.swift @@ -1,6 +1,10 @@ import Foundation @preconcurrency import SentrySwift +protocol ExpectedCommandError: Error { + var isExpected: Bool { get } +} + struct SentryConfiguration { private static let allowedBreadcrumbDataKeys: Set = [ "apple_docs.technology", @@ -43,6 +47,7 @@ struct SentryConfiguration { private static let allowedMetricNames: Set = [ "apple_docs.response.size", "apple_docs.technology.catalog.count", + "apple_docs.type.catalog.count", "apple_docs.technology.requested", "apple_docs.type.requested", ] @@ -54,6 +59,10 @@ struct SentryConfiguration { environment["TELEMETRY_DISABLED"]?.caseInsensitiveCompare("true") != .orderedSame } + static func isExpected(error: Swift.Error) -> Bool { + (error as? any ExpectedCommandError)?.isExpected == true + } + static func configure(_ options: Options) { options.dsn = dsn options.environment = BuildMetadata.environment diff --git a/Sources/CLI/types/DocumentationType.swift b/Sources/CLI/types/DocumentationType.swift new file mode 100644 index 0000000..9a117b5 --- /dev/null +++ b/Sources/CLI/types/DocumentationType.swift @@ -0,0 +1,6 @@ +struct DocumentationType: Codable, Equatable, Sendable { + let name: String + let kind: String + let path: String + let url: String +} diff --git a/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift index d0772b3..5268df1 100644 --- a/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift +++ b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift @@ -53,6 +53,45 @@ struct AppleDocsCommandIntegrationTests { #expect(output.contains("Declaration\n\n @frozen struct String")) } + @Test("lists MetricKit root types as JSON") + func listsMetricKitTypes() throws { + // -- Arrange -- + let arguments = ["types", "list", "--technology", "MetricKit", "--json"] + + // -- Act -- + let output = try runAppleDocs(arguments) + let types = try JSONDecoder().decode([ListedType].self, from: Data(output.utf8)) + + // -- Assert -- + #expect( + types.contains( + ListedType( + kind: "class", + name: "MetricManager", + path: "metricmanager", + url: "https://developer.apple.com/documentation/metrickit/metricmanager" + ) + ) + ) + } + + @Test("resolves a dotted nested type as JSON") + func resolvesDottedNestedType() throws { + // -- Arrange -- + let arguments = [ + "types", "view", "URLSession.AsyncBytes", + "--technology", "Foundation", + "--json", + ] + + // -- Act -- + let output = try runAppleDocs(arguments) + let document = try JSONDecoder().decode(TypeDocument.self, from: Data(output.utf8)) + + // -- Assert -- + #expect(document.metadata.title == "URLSession.AsyncBytes") + } + @Test("lists stable technologies as JSON") func listsStableTechnologies() throws { // -- Arrange -- @@ -96,6 +135,13 @@ private struct TypeDocument: Decodable { } } +private struct ListedType: Decodable, Equatable { + let kind: String + let name: String + let path: String + let url: String +} + private struct Technology: Decodable, Equatable { let identifier: String let name: String diff --git a/Tests/CLITests/client/AppleDocumentationClientErrorTests.swift b/Tests/CLITests/client/AppleDocumentationClientErrorTests.swift new file mode 100644 index 0000000..3ce6efa --- /dev/null +++ b/Tests/CLITests/client/AppleDocumentationClientErrorTests.swift @@ -0,0 +1,110 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Apple documentation client errors") +struct AppleDocumentationClientErrorTests { + @Test("maps a missing type to discovery guidance") + func mapsMissingTypeToDiscoveryGuidance() async throws { + // -- Arrange -- + let typeURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftdata/model.json") + ) + let technologiesURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/technologies.json") + ) + let rootURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftdata.json") + ) + let client = DefaultAppleDocumentationClient( + dependencies: LookupTestTransport( + responses: [ + typeURL: .init(statusCode: 404, data: Data()), + technologiesURL: .init(statusCode: 200, data: swiftDataCatalogData), + rootURL: .init(statusCode: 200, data: modelRootData), + ] + ) + ) + + // -- Act -- + do { + _ = try await client.fetchType(named: "Model", technology: "SwiftData") + Issue.record("Expected the request to fail") + } catch { + // -- Assert -- + #expect( + error.localizedDescription == """ + No Apple documentation found for 'Model' in SwiftData. + + Did you mean: + Model() + https://developer.apple.com/documentation/swiftdata/model() + + Browse available types: + apple-docs types list --technology "SwiftData" + https://developer.apple.com/documentation/swiftdata + """ + ) + } + } +} + +private let swiftDataCatalogData = Data( + """ + { + "sections": [{ + "groups": [{ + "technologies": [{ + "destination": { + "identifier": "doc://com.apple.documentation/documentation/SwiftData" + }, + "title": "SwiftData" + }] + }] + }] + } + """.utf8 +) + +private let modelRootData = Data( + """ + { + "references": { + "doc://model": { + "fragments": [{"kind": "keyword", "text": "macro"}], + "kind": "symbol", + "role": "symbol", + "title": "Model()", + "url": "/documentation/swiftdata/model()" + } + } + } + """.utf8 +) + +private struct LookupTestTransport: HTTPDataTransport { + struct Response: Sendable { + let statusCode: Int + let data: Data + } + + let responses: [URL: Response] + + func data(from url: URL) async throws -> (Data, URLResponse) { + guard let result = responses[url] else { + throw LookupTestError.unexpectedURL(url) + } + let response = HTTPURLResponse( + url: url, + statusCode: result.statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (result.data, response) + } +} + +private enum LookupTestError: Error { + case unexpectedURL(URL) +} diff --git a/Tests/CLITests/client/AppleDocumentationClientTests.swift b/Tests/CLITests/client/AppleDocumentationClientTests.swift index 2e61837..f378bcb 100644 --- a/Tests/CLITests/client/AppleDocumentationClientTests.swift +++ b/Tests/CLITests/client/AppleDocumentationClientTests.swift @@ -47,13 +47,10 @@ struct AppleDocumentationClientTests { ) #expect(document.data == data) - #expect(document.page.metadata.title == "MXHangDiagnostic") - #expect(document.page.metadata.modules.map(\.name) == ["MetricKit"]) - #expect(document.page.abstract.first?.text == "A diagnostic report.") } - @Test("decodes a page with an untitled image reference") - func decodesUntitledImageReference() async throws { + @Test("preserves a successful response that the text renderer cannot decode") + func preservesUndecodableResponse() async throws { // -- Arrange -- let expectedURL = try #require( URL(string: "https://developer.apple.com/tutorials/data/documentation/swift/string.json") @@ -66,11 +63,12 @@ struct AppleDocumentationClientTests { headerFields: ["Content-Type": "application/json"] ) ) + let data = Data("{\"newUpstreamShape\":true}".utf8) let client = DefaultAppleDocumentationClient( dependencies: TypePageTransport( expectedURL: expectedURL, response: response, - data: untitledImageReferencePageData + data: data ) ) @@ -78,8 +76,42 @@ struct AppleDocumentationClientTests { let document = try await client.fetchType(named: "String", technology: "Swift") // -- Assert -- - #expect(document.page.metadata.title == "String") - #expect(document.page.references.keys.contains("Swift-PageImage-card.png")) + #expect(document.data == data) + } + + @Test( + "resolves nested type names as documentation path components", + arguments: ["URLSession.AsyncBytes", "URLSession/AsyncBytes"] + ) + func resolvesNestedTypePath(name: String) async throws { + // -- Arrange -- + let expectedURL = try #require( + URL( + string: + "https://developer.apple.com/tutorials/data/documentation/foundation/urlsession/asyncbytes.json" + ) + ) + let response = try #require( + HTTPURLResponse( + url: expectedURL, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + ) + ) + let client = DefaultAppleDocumentationClient( + dependencies: TypePageTransport( + expectedURL: expectedURL, + response: response, + data: Data("{}".utf8) + ) + ) + + // -- Act -- + _ = try await client.fetchType(named: name, technology: "Foundation") + + // -- Assert -- + // The transport rejects any request that does not use the expected nested URL. } @Test("reports unsuccessful documentation responses") @@ -90,7 +122,7 @@ struct AppleDocumentationClientTests { let response = try #require( HTTPURLResponse( url: expectedURL, - statusCode: 404, + statusCode: 500, httpVersion: nil, headerFields: nil ) @@ -107,44 +139,11 @@ struct AppleDocumentationClientTests { _ = try await client.fetchType(named: "MissingType", technology: "MetricKit") Issue.record("Expected the request to fail") } catch let error as DefaultAppleDocumentationClient.Error { - #expect(error == .httpStatus(404)) + #expect(error == .httpStatus(500)) } } } -private let untitledImageReferencePageData = Data( - """ - { - "abstract": [{"text": "A Unicode string value.", "type": "text"}], - "metadata": { - "modules": [{"name": "Swift"}], - "platforms": [], - "roleHeading": "Structure", - "symbolKind": "struct", - "title": "String" - }, - "primaryContentSections": [], - "references": { - "Swift-PageImage-card.png": { - "alt": "An orange Swift logo on a gradient background.", - "identifier": "Swift-PageImage-card.png", - "type": "image", - "variants": [ - { - "traits": ["2x", "light"], - "url": "/images/com.apple.Swift/Swift-PageImage-card@2x.png" - }, - { - "traits": ["2x", "dark"], - "url": "/images/com.apple.Swift/Swift-PageImage-card~dark@2x.png" - } - ] - } - } - } - """.utf8 -) - private struct TypePageTransport: HTTPDataTransport { let expectedURL: URL let response: URLResponse diff --git a/Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift b/Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift new file mode 100644 index 0000000..e5e788b --- /dev/null +++ b/Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift @@ -0,0 +1,211 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Apple documentation type catalog client") +struct AppleDocumentationClientTypeListTests { + @Test("lists direct symbols from a technology root document") + func listsTechnologyRootSymbols() async throws { + // -- Arrange -- + let rootURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftdata.json") + ) + let client = DefaultAppleDocumentationClient( + dependencies: TypeCatalogTestTransport( + responses: [rootURL: .init(statusCode: 200, data: swiftDataRootData)] + ) + ) + + // -- Act -- + let types = try await client.fetchTypes(technology: "SwiftData") + + // -- Assert -- + #expect(types.map(\.name) == ["Index(_:)", "Model()"]) + #expect(types.map(\.kind) == ["macro", "macro"]) + #expect(types.map(\.path) == ["index(_:)-74ia2", "model()"]) + #expect( + types.map(\.url) + == [ + "https://developer.apple.com/documentation/swiftdata/index(_:)-74ia2", + "https://developer.apple.com/documentation/swiftdata/model()", + ] + ) + } + + @Test("maps a missing resolved root to unsupported technology guidance") + func mapsMissingResolvedRoot() async throws { + // -- Arrange -- + let requestedRootURL = try #require( + URL( + string: + "https://developer.apple.com/tutorials/data/documentation/apple%20cryptokit.json" + ) + ) + let technologiesURL = try #require( + URL( + string: + "https://developer.apple.com/tutorials/data/documentation/technologies.json" + ) + ) + let resolvedRootURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/cryptokit.json") + ) + let client = DefaultAppleDocumentationClient( + dependencies: TypeCatalogTestTransport( + responses: [ + requestedRootURL: .init(statusCode: 404, data: Data()), + technologiesURL: .init(statusCode: 200, data: cryptoKitCatalogData), + resolvedRootURL: .init(statusCode: 404, data: Data()), + ] + ) + ) + + // -- Act -- + do { + _ = try await client.fetchTypes(technology: "Apple CryptoKit") + Issue.record("Expected the request to fail") + } catch { + // -- Assert -- + #expect( + error.localizedDescription == """ + Type retrieval is unavailable for Apple CryptoKit. + + Continue in the technology documentation: + https://developer.apple.com/documentation/cryptokit + """ + ) + } + } + + @Test("links to unsupported external technology documentation") + func linksUnsupportedTechnology() async throws { + // -- Arrange -- + let rootURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/carekit.json") + ) + let technologiesURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/technologies.json") + ) + let client = DefaultAppleDocumentationClient( + dependencies: TypeCatalogTestTransport( + responses: [ + rootURL: .init(statusCode: 404, data: Data()), + technologiesURL: .init(statusCode: 200, data: externalTechnologyCatalogData), + ] + ) + ) + + // -- Act -- + do { + _ = try await client.fetchTypes(technology: "CareKit") + Issue.record("Expected the request to fail") + } catch { + // -- Assert -- + #expect( + error.localizedDescription == """ + Type retrieval is unavailable for CareKit. + + Continue in the technology documentation: + https://carekit-apple.github.io/CareKit/documentation/carekit + """ + ) + } + } +} + +private let swiftDataRootData = Data( + """ + { + "references": { + "doc://module": { + "fragments": [{"kind": "identifier", "text": "SwiftData"}], + "kind": "symbol", + "role": "collection", + "title": "SwiftData", + "url": "/documentation/swiftdata" + }, + "doc://article": { + "kind": "article", + "role": "article", + "title": "Using SwiftData", + "url": "/documentation/swiftdata/using-swiftdata" + }, + "doc://model": { + "fragments": [{"kind": "keyword", "text": "macro"}], + "kind": "symbol", + "role": "symbol", + "title": "Model()", + "url": "/documentation/swiftdata/model()" + }, + "doc://index": { + "fragments": [{"kind": "keyword", "text": "macro"}], + "kind": "symbol", + "role": "symbol", + "title": "Index(_:)", + "url": "/documentation/swiftdata/index(_:)-74ia2" + } + } + } + """.utf8 +) + +private let cryptoKitCatalogData = Data( + """ + { + "sections": [{ + "groups": [{ + "technologies": [{ + "destination": { + "identifier": "doc://com.apple.documentation/documentation/CryptoKit" + }, + "title": "Apple CryptoKit" + }] + }] + }] + } + """.utf8 +) + +private let externalTechnologyCatalogData = Data( + """ + { + "sections": [{ + "groups": [{ + "technologies": [{ + "destination": { + "identifier": "https://carekit-apple.github.io/CareKit/documentation/carekit" + }, + "title": "CareKit" + }] + }] + }] + } + """.utf8 +) + +private struct TypeCatalogTestTransport: HTTPDataTransport { + struct Response: Sendable { + let statusCode: Int + let data: Data + } + + let responses: [URL: Response] + + func data(from url: URL) async throws -> (Data, URLResponse) { + guard let result = responses[url] else { + throw TypeCatalogTestError.unexpectedURL(url) + } + let response = HTTPURLResponse( + url: url, + statusCode: result.statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (result.data, response) + } +} + +private enum TypeCatalogTestError: Error { + case unexpectedURL(URL) +} diff --git a/Tests/CLITests/cmd/types/TypesListCommandRunnerTests.swift b/Tests/CLITests/cmd/types/TypesListCommandRunnerTests.swift new file mode 100644 index 0000000..eb52245 --- /dev/null +++ b/Tests/CLITests/cmd/types/TypesListCommandRunnerTests.swift @@ -0,0 +1,57 @@ +import Testing + +@testable import CLI + +@Suite("Types list command runner") +struct TypesListCommandRunnerTests { + @Test("fetches and renders direct types for the requested technology") + func fetchesAndRendersTypes() async throws { + // -- Arrange -- + let types = [ + DocumentationType( + name: "Model()", + kind: "macro", + path: "model()", + url: "https://developer.apple.com/documentation/swiftdata/model()" + ) + ] + let runner = TypesListCommandRunner( + client: RequestedTypesClient(types: types), + renderer: RequestedTypesRenderer(expectedTypes: types) + ) + + // -- Act -- + let result = try await runner.run(technology: "SwiftData") + + // -- Assert -- + #expect(result.output == "rendered types") + #expect(result.typeCount == 1) + } +} + +private struct RequestedTypesClient: DocumentationTypeCatalogClient { + let types: [DocumentationType] + + func fetchTypes(technology: String) async throws -> [DocumentationType] { + guard technology == "SwiftData" else { + throw TypesListRunnerTestError.unexpectedTechnology + } + return types + } +} + +private struct RequestedTypesRenderer: DocumentationTypeListRenderer { + let expectedTypes: [DocumentationType] + + func render(_ types: [DocumentationType]) throws -> String { + guard types == expectedTypes else { + throw TypesListRunnerTestError.unexpectedTypes + } + return "rendered types" + } +} + +private enum TypesListRunnerTestError: Error { + case unexpectedTechnology + case unexpectedTypes +} diff --git a/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift b/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift index c37adfa..dd4f438 100644 --- a/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift +++ b/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift @@ -24,15 +24,14 @@ struct TypesViewCommandRunnerTests { } """.utf8 ) - let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) - let document = TypeDocumentationDocument(data: data, page: page) + let document = TypeDocumentationDocument(data: data) let client = RequestedTypeClient( expectedName: "MXHangDiagnostic", expectedTechnology: "MetricKit", document: document ) let renderer = RequestedTypeRenderer( - expectedTitle: "MXHangDiagnostic", + expectedData: data, output: "rendered documentation" ) let runner = TypesViewCommandRunner(client: client, renderer: renderer) @@ -63,11 +62,11 @@ private struct RequestedTypeClient: AppleDocumentationClient { } private struct RequestedTypeRenderer: TypeDocumentationRenderer { - let expectedTitle: String + let expectedData: Data let output: String func render(_ document: TypeDocumentationDocument) -> String { - guard document.page.metadata.title == expectedTitle else { + guard document.data == expectedData else { return "unexpected document" } return output diff --git a/Tests/CLITests/cmd/types/TypesViewCommandTests.swift b/Tests/CLITests/cmd/types/TypesViewCommandTests.swift index 21d9a50..9400f7e 100644 --- a/Tests/CLITests/cmd/types/TypesViewCommandTests.swift +++ b/Tests/CLITests/cmd/types/TypesViewCommandTests.swift @@ -2,8 +2,26 @@ import Testing @testable import CLI -@Suite("Types view command parsing") +@Suite("Types command parsing") struct TypesViewCommandTests { + @Test("accepts the types list command with a required technology") + func parsesTypesList() throws { + // -- Arrange -- + let arguments = [ + "types", "list", + "--technology", "MetricKit", + "--json", + ] + + // -- Act -- + let command = try CLI.parseAsRoot(arguments) + + // -- Assert -- + let listCommand = try #require(command as? TypesListCommand) + #expect(listCommand.technology == "MetricKit") + #expect(listCommand.json) + } + @Test("accepts a type name and required technology option") func parsesTypeNameAndTechnology() throws { // -- Arrange -- diff --git a/Tests/CLITests/renderer/DefaultDocumentationTypeListRendererTests.swift b/Tests/CLITests/renderer/DefaultDocumentationTypeListRendererTests.swift new file mode 100644 index 0000000..bd9dede --- /dev/null +++ b/Tests/CLITests/renderer/DefaultDocumentationTypeListRendererTests.swift @@ -0,0 +1,62 @@ +import Testing + +@testable import CLI + +@Suite("Default documentation type list renderer") +struct DefaultDocumentationTypeListRendererTests { + @Test("renders documentation types as a table") + func rendersTable() throws { + // -- Arrange -- + let types = [ + DocumentationType( + name: "Model()", + kind: "macro", + path: "model()", + url: "https://developer.apple.com/documentation/swiftdata/model()" + ) + ] + let renderer = DefaultDocumentationTypeListRenderer(output: .table) + + // -- Act -- + let output = try renderer.render(types) + + // -- Assert -- + #expect( + output == """ + SYMBOL KIND PATH URL + Model() macro model() https://developer.apple.com/documentation/swiftdata/model() + """ + ) + } + + @Test("renders documentation types as JSON") + func rendersJSON() throws { + // -- Arrange -- + let types = [ + DocumentationType( + name: "Model()", + kind: "macro", + path: "model()", + url: "https://developer.apple.com/documentation/swiftdata/model()" + ) + ] + let renderer = DefaultDocumentationTypeListRenderer(output: .json) + + // -- Act -- + let output = try renderer.render(types) + + // -- Assert -- + #expect( + output == """ + [ + { + "kind" : "macro", + "name" : "Model()", + "path" : "model()", + "url" : "https://developer.apple.com/documentation/swiftdata/model()" + } + ] + """ + ) + } +} diff --git a/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift b/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift index d617bbd..3b7a186 100644 --- a/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift +++ b/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift @@ -24,7 +24,7 @@ struct DefaultTypeDocumentationRendererTests { let document = try makeDocument(rawJSON) let renderer = DefaultTypeDocumentationRenderer(output: .text) - let output = renderer.render(document) + let output = try renderer.render(document) #expect( output == """ @@ -38,31 +38,20 @@ struct DefaultTypeDocumentationRendererTests { @Test("returns Apple's DocC JSON unchanged") func rendersRawJSON() throws { - let rawJSON = """ - { - "abstract": [], - "metadata": { - "modules": [{"name": "MetricKit"}], - "platforms": [], - "roleHeading": "Class", - "symbolKind": "class", - "title": "MXHangDiagnostic" - }, - "primaryContentSections": [], - "references": {} - } - """ + // -- Arrange -- + // This intentionally omits the fields required by the text renderer. + let rawJSON = "{\"newUpstreamShape\":true}" let document = try makeDocument(rawJSON) let renderer = DefaultTypeDocumentationRenderer(output: .json) - let output = renderer.render(document) + // -- Act -- + let output = try renderer.render(document) + // -- Assert -- #expect(output == rawJSON) } private func makeDocument(_ rawJSON: String) throws -> TypeDocumentationDocument { - let data = Data(rawJSON.utf8) - let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) - return TypeDocumentationDocument(data: data, page: page) + TypeDocumentationDocument(data: Data(rawJSON.utf8)) } } diff --git a/Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift b/Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift new file mode 100644 index 0000000..a49260c --- /dev/null +++ b/Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Text type documentation reference rendering") +struct TextTypeDocumentationRendererReferenceTests { + @Test("renders inline references and follow-up links in documentation sections") + func rendersInlineReferencesAndLinks() throws { + // -- Arrange -- + let data = Data( + """ + { + "abstract": [], + "metadata": { + "modules": [{"name": "SwiftUI"}], + "platforms": [], + "roleHeading": "Structure", + "symbolKind": "struct", + "title": "Button" + }, + "primaryContentSections": [], + "references": { + "doc://button-init": { + "abstract": [ + {"text": "Creates a button that performs an ", "type": "text"}, + {"code": "AppIntent", "type": "codeVoice"}, + {"text": ".", "type": "text"} + ], + "kind": "symbol", + "role": "symbol", + "title": "init(intent:label:)", + "type": "topic", + "url": "/documentation/swiftui/button/init(intent:label:)" + } + }, + "topicSections": [{ + "identifiers": ["doc://button-init"], + "title": "Creating a button" + }] + } + """.utf8 + ) + let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) + + // -- Act -- + let output = TextTypeDocumentationRenderer().render(page) + + // -- Assert -- + #expect(output.contains("init(intent:label:) — Creates a button that performs an AppIntent.")) + #expect( + output.contains( + "https://developer.apple.com/documentation/swiftui/button/init(intent:label:)" + ) + ) + } +} diff --git a/Tests/CLITests/telemetry/SentryCommandContextTests.swift b/Tests/CLITests/telemetry/SentryCommandContextTests.swift index 239c629..68df12d 100644 --- a/Tests/CLITests/telemetry/SentryCommandContextTests.swift +++ b/Tests/CLITests/telemetry/SentryCommandContextTests.swift @@ -31,6 +31,31 @@ struct SentryCommandContextTests { #expect(context.logMetadata.keys.sorted() == expectedKeys) } + @Test("opts technology and output mode into types list telemetry") + func includesTypesListContext() { + // -- Arrange -- + let expectedKeys = [ + "apple_docs.technology", + "cli.command", + "cli.output_json", + ] + + // -- Act -- + let context = SentryCommandContext.typesList( + technology: "SwiftData", + json: true + ) + + // -- Assert -- + #expect(context.command == "types.list") + #expect(context.typeName == nil) + #expect(context.technology == "SwiftData") + #expect(context.outputJSON == true) + #expect(context.attributes.keys.sorted() == expectedKeys) + #expect(context.metricAttributes.keys.sorted() == expectedKeys.dropLast()) + #expect(context.logMetadata.keys.sorted() == expectedKeys) + } + @Test("excludes the skill name from agent command telemetry") func excludesAgentSkillName() { // -- Arrange -- diff --git a/Tests/CLITests/telemetry/SentryConfigurationTests.swift b/Tests/CLITests/telemetry/SentryConfigurationTests.swift index 5663f63..c1f4f7a 100644 --- a/Tests/CLITests/telemetry/SentryConfigurationTests.swift +++ b/Tests/CLITests/telemetry/SentryConfigurationTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import CLI @@ -31,6 +32,23 @@ struct SentryConfigurationTests { #expect(!enabled) } + @Test("treats documentation lookup failures as expected command errors") + func treatsLookupFailureAsExpected() { + // -- Arrange -- + let error = DefaultAppleDocumentationClient.Error.typeNotFound( + name: "Model", + technology: "SwiftData", + suggestion: nil, + technologyURL: "https://developer.apple.com/documentation/swiftdata" + ) + + // -- Act -- + let expected = SentryConfiguration.isExpected(error: error) + + // -- Assert -- + #expect(expected) + } + @Test("keeps telemetry enabled for other environmental flag values") func ignoresOtherFlagValues() { // -- Arrange -- diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index ad4e9a1..29a68e7 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -4,7 +4,7 @@ ## Principles -Telemetry follows an explicit opt-in model: +Telemetry follows an explicit allowlist model and is enabled by default. Set `TELEMETRY_DISABLED=true` to opt out: - Capture only fields that have been reviewed and approved. - Treat new commands, arguments, and metadata as private by default. @@ -30,6 +30,7 @@ Each leaf command starts a `console.command` transaction and opts in to a fixed | Command | Captured fields | | ------------------- | ------------------------------------------------------------------ | | `types view` | Command name, documentation type, technology, and JSON output mode | +| `types list` | Command name, technology, and JSON output mode | | `technologies list` | Command name and JSON output mode | | `agent skills list` | Command name | | `agent skills get` | Command name | @@ -56,6 +57,7 @@ The CLI records: - `apple_docs.type.requested` to measure type popularity within a technology. - `apple_docs.response.size` to monitor Apple documentation response sizes. - `apple_docs.technology.catalog.count` to monitor the size of the technology catalog. +- `apple_docs.type.catalog.count` to monitor direct type counts in technology root documents. Metric names and attributes are allowlisted. Type and technology are the only variable popularity dimensions.