diff --git a/Sources/XCDocsCLI/SearchCommand.swift b/Sources/XCDocsCLI/SearchCommand.swift index a818346..47baaa4 100644 --- a/Sources/XCDocsCLI/SearchCommand.swift +++ b/Sources/XCDocsCLI/SearchCommand.swift @@ -54,26 +54,38 @@ struct SearchCommand: AsyncParsableCommand { } @available(macOS 26, *) -private func printDocumentationSearchJSON(_ results: [SearchResult]) throws { - struct DocumentationSearchDocument: Encodable { - let contents: String - let score: Double - let title: String - let uri: String - - init(searchResult: SearchResult) { - self.contents = searchResult.entry.content ?? "" - self.score = searchResult.score - self.title = searchResult.entry.title ?? "" - self.uri = searchResult.entry.id - } +struct DocumentationSearchDocument: Encodable { + let contents: String? + let score: Double + let title: String? + let uri: String + + init(searchResult: SearchResult) { + self.contents = searchResult.entry.content + self.score = searchResult.score + self.title = searchResult.entry.title + self.uri = searchResult.entry.id } - struct DocumentationSearchResponse: Encodable { - let documents: [DocumentationSearchDocument] - - init(searchResults: [SearchResult]) { self.documents = searchResults.map(DocumentationSearchDocument.init) } + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(contents, forKey: .contents) + try container.encode(score, forKey: .score) + try container.encode(title, forKey: .title) + try container.encode(uri, forKey: .uri) } + private enum CodingKeys: String, CodingKey { case contents, score, title, uri } +} + +@available(macOS 26, *) +struct DocumentationSearchResponse: Encodable { + let documents: [DocumentationSearchDocument] + + init(searchResults: [SearchResult]) { self.documents = searchResults.map(DocumentationSearchDocument.init) } +} + +@available(macOS 26, *) +private func printDocumentationSearchJSON(_ results: [SearchResult]) throws { try printJSON(DocumentationSearchResponse(searchResults: results), prettyPrinted: false) } diff --git a/Tests/XCDocsCLITests/SearchJSONOutputTests.swift b/Tests/XCDocsCLITests/SearchJSONOutputTests.swift new file mode 100644 index 0000000..3df27f1 --- /dev/null +++ b/Tests/XCDocsCLITests/SearchJSONOutputTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +import XCDocs + +@testable import XCDocsCLI + +@Suite("Search JSON Output") +struct SearchJSONOutputTests { + @Test + func searchDocumentEmitsNullForNilFields() throws { + guard #available(macOS 26, *) else { return } + try assertSearchDocumentEmitsNullForNilFields() + } + + @Test + func searchDocumentPreservesNonNilValues() throws { + guard #available(macOS 26, *) else { return } + try assertSearchDocumentPreservesNonNilValues() + } +} + +@available(macOS 26, *) +private func assertSearchDocumentEmitsNullForNilFields() throws { + let entry = DocumentationEntry(id: "/documentation/Testing", framework: nil, kind: nil, title: nil, content: nil) + let result = SearchResult(score: 0.5, entry: entry) + + let response = DocumentationSearchResponse(searchResults: [result]) + let data = try JSONEncoder().encode(response) + let string = try #require(String(data: data, encoding: .utf8)) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let documents = try #require(json["documents"] as? [[String: Any]]) + let doc = try #require(documents.first) + + // nil fields must be encoded as JSON null, not coerced to empty strings or omitted. + #expect(doc["title"] is NSNull, "Expected null for nil title, got \(String(describing: doc["title"]))") + #expect(doc["contents"] is NSNull, "Expected null for nil contents, got \(String(describing: doc["contents"]))") + #expect(!string.contains("\"title\":\"\""), "title should not be an empty string") + #expect(!string.contains("\"contents\":\"\""), "contents should not be an empty string") + #expect(doc["uri"] as? String == "/documentation/Testing") + #expect(doc["score"] as? Double == 0.5) +} + +@available(macOS 26, *) +private func assertSearchDocumentPreservesNonNilValues() throws { + let entry = DocumentationEntry( + id: "/documentation/Testing", + framework: "Swift Testing", + kind: .article, + title: "Swift Testing", + content: "Create and run tests." + ) + let result = SearchResult(score: 0.75, entry: entry) + + let response = DocumentationSearchResponse(searchResults: [result]) + let data = try JSONEncoder().encode(response) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let documents = try #require(json["documents"] as? [[String: Any]]) + let doc = try #require(documents.first) + + #expect(doc["title"] as? String == "Swift Testing") + #expect(doc["contents"] as? String == "Create and run tests.") + #expect(doc["uri"] as? String == "/documentation/Testing") + #expect(doc["score"] as? Double == 0.75) +}