krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.7.0: DomainReportExporter.swift · raw
1import Foundation
2
3enum DomainExportFormat: String {
4 case text = "txt"
5 case csv = "csv"
6 case json = "json"
7
8 var fileExtension: String { rawValue }
9}
10
11enum DomainReportExporter {
12 static func data(for report: DomainReport, format: DomainExportFormat) throws -> Data {
13 switch format {
14 case .text:
15 return Data(text(for: report).utf8)
16 case .csv:
17 return Data(csv(for: [report]).utf8)
18 case .json:
19 return try jsonEncoder.encode(report)
20 }
21 }
22
23 static func data(for reports: [DomainReport], format: DomainExportFormat, title: String) throws -> Data {
24 switch format {
25 case .text:
26 return Data(batchText(for: reports, title: title).utf8)
27 case .csv:
28 return Data(csv(for: reports).utf8)
29 case .json:
30 return try jsonEncoder.encode(reports)
31 }
32 }
33
34 static func text(for report: DomainReport) -> String {
35 var lines = [
36 "DomainDig Report",
37 "Domain: \(report.domain)",
38 "Timestamp: \(textDateFormatter.string(from: report.timestamp))",
39 "App Version: \(report.metadata.appVersion)",
40 "Resolver: \(report.metadata.resolverDisplayName)",
41 "Resolver URL: \(report.metadata.resolverURLString)",
42 "Source: \(report.provenance.source.label)",
43 "Availability: \(availabilityLabel(report.availability))",
44 "Availability Confidence: \(report.availabilityConfidence?.title ?? "N/A")"
45 ]
46
47 if report.metadata.isPartialSnapshot {
48 lines.append("Snapshot Integrity: Partial snapshot")
49 }
50 if let auditNote = report.metadata.auditNote, !auditNote.isEmpty {
51 lines.append("Audit Note: \(auditNote)")
52 }
53 if !report.provenance.dataSources.isEmpty {
54 lines.append("Data Sources: \(report.provenance.dataSources.joined(separator: ", "))")
55 }
56 if !report.metadata.validationIssues.isEmpty {
57 lines.append("Validation: \(report.metadata.validationIssues.joined(separator: " | "))")
58 }
59 if let workflowContext = report.workflowContext {
60 lines.append("Workflow Context: \(workflowContext.workflowName ?? workflowContext.source)")
61 }
62
63 appendSection("Summary", to: &lines) {
64 [
65 "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
66 "Risk Score: \(report.riskAssessment.score) (\(report.riskAssessment.level.title))",
67 "TLS Status: \(report.web.tlsStatus)",
68 "HTTP: \(httpSummary(for: report))",
69 "Email: \(report.email.summary)",
70 "Subdomains: \(report.subdomains.count)",
71 "Extended Subdomains: \(report.extendedSubdomains.count)",
72 "External Price: \(report.domainPricing?.estimatedPrice ?? "Unavailable")",
73 "Reputation: \(report.reputation?.status.title ?? "Unavailable")"
74 ]
75 }
76
77 appendSection("Risk", to: &lines) {
78 var values = [
79 "Score: \(report.riskAssessment.score)",
80 "Level: \(report.riskAssessment.level.title)"
81 ]
82 if report.riskAssessment.factors.isEmpty {
83 values.append("Factors: None")
84 } else {
85 values.append("Factors:")
86 for factor in report.riskAssessment.factors {
87 values.append(" [\(factor.impact.rawValue)] \(factor.description)")
88 }
89 }
90 return values
91 }
92
93 appendSection("Insights", to: &lines) {
94 report.insights.isEmpty ? ["No deterministic insights triggered"] : report.insights.map { "- \($0)" }
95 }
96
97 appendSection("Ownership", to: &lines) {
98 var ownershipLines = [
99 "Registrar: \(report.ownership?.registrar ?? "Unavailable")",
100 "Confidence: \(report.ownershipConfidence?.title ?? "N/A")",
101 "Created: \(ownershipDateLabel(report.ownership?.createdDate))",
102 "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))",
103 "Registrant: \(report.ownership?.registrant ?? "Unavailable")",
104 "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")",
105 "Status: \(joined(report.ownership?.status) ?? "Unavailable")",
106 "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")"
107 ]
108 if let provenance = report.sectionProvenance[.ownership] {
109 ownershipLines.append("Provenance: \(provenanceLabel(provenance))")
110 }
111 if let error = report.dns.error, report.ownership == nil {
112 ownershipLines.append("Error: \(error)")
113 } else if let error = report.changeSummary?.message, report.ownership == nil, report.ownership == nil {
114 _ = error
115 }
116 return ownershipLines
117 }
118
119 appendSection("Ownership History", to: &lines) {
120 guard !report.ownershipHistory.isEmpty else {
121 return ["No ownership history available"]
122 }
123
124 return report.ownershipHistory.map { event in
125 [
126 textDateFormatter.string(from: event.date),
127 event.summary,
128 "source=\(event.source)"
129 ].joined(separator: " | ")
130 }
131 }
132
133 appendSection("DNS", to: &lines) {
134 var dnsLines = [
135 "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))",
136 "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
137 "PTR: \(report.dns.ptrRecord ?? report.dns.ptrError ?? "Unavailable")",
138 "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))",
139 "Patterns: \(report.dns.patternSummary.patterns.joined(separator: " | ").nilIfEmpty ?? "None")"
140 ]
141 if let provenance = report.sectionProvenance[.dns] {
142 dnsLines.append("Provenance: \(provenanceLabel(provenance))")
143 }
144 if let error = report.dns.error {
145 dnsLines.append("Error: \(error)")
146 }
147 if report.dns.recordSections.isEmpty {
148 dnsLines.append("Records: None")
149 } else {
150 dnsLines.append("Records:")
151 for section in report.dns.recordSections {
152 let values = (section.records + section.wildcardRecords).map(\.value)
153 let renderedValues = values.isEmpty ? "None" : values.joined(separator: " | ")
154 dnsLines.append(" \(section.recordType.rawValue): \(renderedValues)")
155 }
156 }
157 return dnsLines
158 }
159
160 appendSection("DNS History", to: &lines) {
161 guard !report.dnsHistory.isEmpty else {
162 return ["No DNS history available"]
163 }
164
165 return report.dnsHistory.map { event in
166 [
167 textDateFormatter.string(from: event.date),
168 event.summary,
169 "A=\(event.aRecords.joined(separator: " | ").nilIfEmpty ?? "-")",
170 "NS=\(event.nameservers.joined(separator: " | ").nilIfEmpty ?? "-")",
171 "source=\(event.source)"
172 ].joined(separator: " | ")
173 }
174 }
175
176 appendSection("Web", to: &lines) {
177 var webLines = [
178 "TLS Status: \(report.web.tlsStatus)",
179 "TLS Grade: \(report.web.tlsGrade.rawValue)",
180 "TLS Highlights: \(report.web.tlsHighlights.joined(separator: " | "))",
181 "Certificate Warning: \(report.web.certificateWarningLevel.title)",
182 "Security Grade: \(report.web.securityGrade ?? "Unavailable")",
183 "HTTP Status: \(report.web.statusCode.map(String.init) ?? "Unavailable")",
184 "Protocol: \(report.web.httpProtocol ?? "Unavailable")",
185 "HTTP/3 Advertised: \(report.web.http3Advertised ? "Yes" : "No")",
186 "Final URL: \(report.web.finalURL ?? "Unavailable")",
187 "HSTS Preloaded: \(booleanLabel(report.web.hstsPreloaded))",
188 "Header Count: \(report.web.headerCount)"
189 ]
190 if let provenance = report.sectionProvenance[.ssl] {
191 webLines.append("TLS Provenance: \(provenanceLabel(provenance))")
192 }
193 if let provenance = report.sectionProvenance[.httpHeaders] {
194 webLines.append("HTTP Provenance: \(provenanceLabel(provenance))")
195 }
196 if let provenance = report.sectionProvenance[.redirectChain] {
197 webLines.append("Redirect Provenance: \(provenanceLabel(provenance))")
198 }
199 if let tlsError = report.web.tlsError {
200 webLines.append("TLS Error: \(tlsError)")
201 }
202 if let headersError = report.web.headersError {
203 webLines.append("Headers Error: \(headersError)")
204 }
205 if let redirectError = report.web.redirectError {
206 webLines.append("Redirect Error: \(redirectError)")
207 }
208 if !report.web.headers.isEmpty {
209 webLines.append("Headers:")
210 for header in report.web.headers {
211 webLines.append(" \(header.name): \(header.value)")
212 }
213 }
214 if !report.web.redirectChain.isEmpty {
215 webLines.append("Redirect Chain:")
216 for hop in report.web.redirectChain {
217 webLines.append(" \(hop.stepNumber). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")")
218 }
219 }
220 return webLines
221 }
222
223 appendSection("Email", to: &lines) {
224 var emailLines = [report.email.summary]
225 if let grade = report.email.grade {
226 emailLines.append("Grade: \(grade.rawValue)")
227 }
228 if !report.email.reasons.isEmpty {
229 emailLines.append("Why: \(report.email.reasons.joined(separator: " | "))")
230 }
231 emailLines.append("Confidence: \(report.emailConfidence?.title ?? "N/A")")
232 if let provenance = report.sectionProvenance[.emailSecurity] {
233 emailLines.append("Provenance: \(provenanceLabel(provenance))")
234 }
235 if let records = report.email.records {
236 emailLines.append("SPF: \(recordLabel(records.spf))")
237 emailLines.append("DMARC: \(recordLabel(records.dmarc))")
238 emailLines.append("DKIM: \(recordLabel(records.dkim))")
239 emailLines.append("BIMI: \(recordLabel(records.bimi))")
240 emailLines.append("MTA-STS: \(records.mtaSts?.txtFound == true ? records.mtaSts?.policyMode ?? "found" : "Unavailable")")
241 }
242 if let error = report.email.error {
243 emailLines.append("Error: \(error)")
244 }
245 return emailLines
246 }
247
248 appendSection("Network", to: &lines) {
249 var networkLines = [
250 "Reachability: \(report.network.reachabilitySummary)",
251 "Geolocation: \(report.network.geolocationSummary)",
252 "Geolocation Confidence: \(report.geolocationConfidence?.title ?? "N/A")",
253 "Open Ports: \(report.network.openPorts.map(String.init).joined(separator: ", ").nilIfEmpty ?? "None")"
254 ]
255 if let provenance = report.sectionProvenance[.ipGeolocation] {
256 networkLines.append("Geolocation Provenance: \(provenanceLabel(provenance))")
257 }
258 if let error = report.network.reachabilityError {
259 networkLines.append("Reachability Error: \(error)")
260 }
261 if let error = report.network.geolocationError {
262 networkLines.append("Geolocation Error: \(error)")
263 }
264 if let error = report.network.portScanError {
265 networkLines.append("Port Scan Error: \(error)")
266 }
267 if !report.network.portScan.isEmpty {
268 networkLines.append("Port Scan:")
269 for result in report.network.portScan {
270 networkLines.append(
271 " \(result.port) \(result.service): \(result.open ? "open" : "closed")\(result.banner.map { " banner=\($0)" } ?? "")"
272 )
273 }
274 }
275 return networkLines
276 }
277
278 appendSection("Subdomains", to: &lines) {
279 var values = ["Confidence: \(report.subdomainConfidence?.title ?? "N/A")"]
280 if let provenance = report.sectionProvenance[.subdomains] {
281 values.append("Provenance: \(provenanceLabel(provenance))")
282 }
283 if report.subdomains.isEmpty {
284 values.append("None")
285 return values
286 }
287 if !report.subdomainGroups.isEmpty {
288 values.append("Groups: \(report.subdomainGroups.map { "\($0.label): \($0.subdomains.count)" }.joined(separator: " | "))")
289 }
290 values.append(contentsOf: report.subdomains.map { "- \($0)" })
291 if !report.extendedSubdomains.isEmpty {
292 values.append("Extended:")
293 values.append(contentsOf: report.extendedSubdomains.map { "- \($0)" })
294 }
295 return values
296 }
297
298 appendSection("Pricing", to: &lines) {
299 guard let pricing = report.domainPricing else {
300 return ["External pricing unavailable"]
301 }
302
303 return [
304 "Estimated Price: \(pricing.estimatedPrice ?? "Unavailable")",
305 "Premium: \(pricing.premiumIndicator == true ? "Yes" : "No")",
306 "Resale: \(pricing.resaleSignal ?? "Unavailable")",
307 "Auction: \(pricing.auctionSignal ?? "Unavailable")",
308 "Source: \(pricing.source)",
309 "Collected: \(textDateFormatter.string(from: pricing.collectedAt))"
310 ]
311 }
312
313 appendSection("Changes", to: &lines) {
314 guard let changeSummary = report.changeSummary else {
315 return ["No comparison available"]
316 }
317
318 var values = [
319 "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")",
320 "Severity: \(changeSummary.severity.title)",
321 "Impact: \(changeSummary.impactClassification.title)",
322 "Inferred Summary: \(changeSummary.message)",
323 "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))"
324 ]
325 if let riskScoreDelta = changeSummary.riskScoreDelta {
326 values.append("Risk Delta: \(riskScoreDelta >= 0 ? "+" : "")\(riskScoreDelta)")
327 }
328 if !changeSummary.insights.isEmpty {
329 values.append("Insights: \(changeSummary.insights.joined(separator: " | "))")
330 }
331 if !changeSummary.observedFacts.isEmpty {
332 values.append("Observed: \(changeSummary.observedFacts.joined(separator: " | "))")
333 }
334 if let contextNote = changeSummary.contextNote {
335 values.append("Context: \(contextNote)")
336 }
337 return values
338 }
339
340 return lines.joined(separator: "\n")
341 }
342
343 static func batchText(for reports: [DomainReport], title: String) -> String {
344 guard !reports.isEmpty else {
345 return "\(title)\nNo results available."
346 }
347
348 var lines = [title, String(repeating: "=", count: title.count), ""]
349 for (index, report) in reports.enumerated() {
350 if index > 0 {
351 lines.append("")
352 lines.append(String(repeating: "=", count: 48))
353 lines.append("")
354 }
355 lines.append(text(for: report))
356 }
357 return lines.joined(separator: "\n")
358 }
359
360 static func csv(for reports: [DomainReport]) -> String {
361 csv(for: reports, workflowInsights: [])
362 }
363
364 static func csv(for reports: [DomainReport], workflowInsights: [WorkflowInsight]) -> String {
365 let workflowInsightSummary = workflowInsights.map(\.description).joined(separator: " | ")
366 let headers = [
367 "domain",
368 "timestamp",
369 "app_version",
370 "result_source",
371 "resolver",
372 "availability",
373 "risk_score",
374 "risk_level",
375 "risk_factors",
376 "insights",
377 "availability_confidence",
378 "registrar",
379 "ownership_confidence",
380 "ownership_expires",
381 "nameservers",
382 "primary_ip",
383 "ptr_record",
384 "dnssec_signed",
385 "dns_patterns",
386 "tls_status",
387 "tls_grade",
388 "tls_highlights",
389 "certificate_warning_level",
390 "hsts_preloaded",
391 "http_status",
392 "http_security_grade",
393 "final_url",
394 "email_summary",
395 "email_grade",
396 "email_confidence",
397 "subdomain_count",
398 "extended_subdomain_count",
399 "subdomain_groups",
400 "subdomain_confidence",
401 "subdomains",
402 "extended_subdomains",
403 "ownership_history",
404 "dns_history",
405 "pricing_estimated",
406 "pricing_premium",
407 "pricing_resale_signal",
408 "pricing_auction_signal",
409 "pricing_source",
410 "reputation_status",
411 "reputation_listed_sources",
412 "open_ports",
413 "reachability_summary",
414 "geolocation_summary",
415 "geolocation_confidence",
416 "data_sources",
417 "audit_note",
418 "partial_snapshot",
419 "workflow_name",
420 "workflow_source",
421 "cached_sections",
422 "change_summary",
423 "change_impact",
424 "workflow_insights"
425 ]
426
427 let rows = reports.map { report in
428 let expirationDate = report.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? ""
429 let nameservers = joined(report.ownership?.nameservers) ?? ""
430 let dnssecSigned = report.dns.dnssecSigned.map { $0 ? "true" : "false" } ?? ""
431 let hstsPreloaded = report.web.hstsPreloaded.map { $0 ? "true" : "false" } ?? ""
432 let httpStatus = report.web.statusCode.map(String.init) ?? ""
433 let subdomainCount = String(report.subdomains.count)
434 let subdomains = report.subdomains.joined(separator: " | ")
435 let extendedSubdomains = report.extendedSubdomains.joined(separator: " | ")
436 let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ")
437 let riskFactors = report.riskAssessment.factors.map(\.description).joined(separator: " | ")
438 let insights = report.insights.joined(separator: " | ")
439 let dnsPatterns = report.dns.patternSummary.patterns.joined(separator: " | ")
440 let tlsHighlights = report.web.tlsHighlights.joined(separator: " | ")
441 let subdomainGroups = report.subdomainGroups.map { "\($0.label):\($0.subdomains.count)" }.joined(separator: " | ")
442 let ownershipHistory = report.ownershipHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
443 let dnsHistory = report.dnsHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
444 let reputationStatus = report.reputation?.status.rawValue ?? ""
445 let reputationListedSources = report.reputation?.listedSources.joined(separator: " | ") ?? ""
446
447 return [
448 report.domain,
449 csvDateFormatter.string(from: report.timestamp),
450 report.appVersion,
451 report.resultSource.rawValue,
452 report.resolverDisplayName,
453 availabilityLabel(report.availability),
454 "\(report.riskAssessment.score)",
455 report.riskAssessment.level.rawValue,
456 riskFactors,
457 insights,
458 report.availabilityConfidence?.rawValue ?? "",
459 report.ownership?.registrar ?? "",
460 report.ownershipConfidence?.rawValue ?? "",
461 expirationDate,
462 nameservers,
463 report.dns.primaryIP ?? "",
464 report.dns.ptrRecord ?? "",
465 dnssecSigned,
466 dnsPatterns,
467 report.web.tlsStatus,
468 report.web.tlsGrade.rawValue,
469 tlsHighlights,
470 report.web.certificateWarningLevel.rawValue,
471 hstsPreloaded,
472 httpStatus,
473 report.web.securityGrade ?? "",
474 report.web.finalURL ?? "",
475 report.email.summary,
476 report.email.grade?.rawValue ?? "",
477 report.emailConfidence?.rawValue ?? "",
478 subdomainCount,
479 String(report.extendedSubdomains.count),
480 subdomainGroups,
481 report.subdomainConfidence?.rawValue ?? "",
482 subdomains,
483 extendedSubdomains,
484 ownershipHistory,
485 dnsHistory,
486 report.domainPricing?.estimatedPrice ?? "",
487 report.domainPricing?.premiumIndicator == true ? "true" : "false",
488 report.domainPricing?.resaleSignal ?? "",
489 report.domainPricing?.auctionSignal ?? "",
490 report.domainPricing?.source ?? "",
491 reputationStatus,
492 reputationListedSources,
493 openPorts,
494 report.network.reachabilitySummary,
495 report.network.geolocationSummary,
496 report.geolocationConfidence?.rawValue ?? "",
497 report.provenance.dataSources.joined(separator: " | "),
498 report.metadata.auditNote ?? "",
499 report.metadata.isPartialSnapshot ? "true" : "false",
500 report.workflowContext?.workflowName ?? "",
501 report.workflowContext?.source ?? "",
502 report.metadata.cachedSections.map(\.rawValue).joined(separator: " | "),
503 report.changeSummary?.message ?? "",
504 report.changeSummary?.impactClassification.rawValue ?? "",
505 workflowInsightSummary
506 ]
507 }
508
509 return ([headers] + rows)
510 .map { row in row.map(csvEscaped).joined(separator: ",") }
511 .joined(separator: "\n")
512 }
513
514 static func timelineText(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) -> String {
515 guard !reports.isEmpty else {
516 return "Timeline Export\nNo snapshots available for \(domain)."
517 }
518
519 var lines = [
520 "DomainDig Timeline Export",
521 "Domain: \(domain)",
522 "Snapshots: \(reports.count)"
523 ]
524
525 for report in reports.sorted(by: { $0.timestamp > $1.timestamp }) {
526 lines.append("")
527 lines.append("\(textDateFormatter.string(from: report.timestamp))")
528 lines.append("Summary: \(report.changeSummary?.message ?? "No change summary")")
529 lines.append("Severity: \(report.changeSummary?.severity.title ?? "N/A")")
530 if includeDiffSummary, let changeSummary = report.changeSummary {
531 lines.append("Changed Sections: \(changeSummary.changedSections.joined(separator: ", ").nilIfEmpty ?? "None")")
532 }
533 }
534
535 return lines.joined(separator: "\n")
536 }
537
538 static func timelineData(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) throws -> Data {
539 struct TimelineExportEntry: Codable {
540 let timestamp: Date
541 let summary: String?
542 let severity: String?
543 let changedSections: [String]?
544 }
545
546 struct TimelineExportPayload: Codable {
547 let domain: String
548 let exportedAt: Date
549 let snapshots: [TimelineExportEntry]
550 }
551
552 let payload = TimelineExportPayload(
553 domain: domain,
554 exportedAt: Date(),
555 snapshots: reports
556 .sorted(by: { $0.timestamp > $1.timestamp })
557 .map { report in
558 TimelineExportEntry(
559 timestamp: report.timestamp,
560 summary: report.changeSummary?.message,
561 severity: report.changeSummary?.severity.title,
562 changedSections: includeDiffSummary ? report.changeSummary?.changedSections : nil
563 )
564 }
565 )
566
567 return try jsonEncoder.encode(payload)
568 }
569
570 private static func appendSection(_ title: String, to lines: inout [String], body: () -> [String]) {
571 lines.append("")
572 lines.append(title)
573 lines.append(String(repeating: "-", count: title.count))
574 lines.append(contentsOf: body())
575 }
576
577 private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String {
578 switch status {
579 case .available:
580 return "Available"
581 case .registered:
582 return "Registered"
583 case .unknown:
584 return "Unknown"
585 }
586 }
587
588 private static func joined(_ values: [String]?) -> String? {
589 guard let values, !values.isEmpty else { return nil }
590 return values.joined(separator: " | ")
591 }
592
593 private static func dnssecLabel(_ value: Bool?) -> String {
594 switch value {
595 case true:
596 return "Signed"
597 case false:
598 return "Unsigned"
599 case nil:
600 return "Unavailable"
601 }
602 }
603
604 private static func ownershipDateLabel(_ date: Date?) -> String {
605 guard let date else { return "Unavailable" }
606 return textDateFormatter.string(from: date)
607 }
608
609 private static func durationLabel(_ durationMs: Int?) -> String {
610 durationMs.map { "\($0) ms" } ?? "Unavailable"
611 }
612
613 private static func httpSummary(for report: DomainReport) -> String {
614 let parts = [report.web.statusCode.map(String.init), report.web.securityGrade].compactMap { $0 }
615 return parts.isEmpty ? report.web.headersError ?? "Unavailable" : parts.joined(separator: " / ")
616 }
617
618 private static func booleanLabel(_ value: Bool?) -> String {
619 guard let value else { return "Unavailable" }
620 return value ? "Yes" : "No"
621 }
622
623 private static func recordLabel(_ record: EmailSecurityRecord) -> String {
624 if record.found {
625 return record.value ?? "Present"
626 }
627 return "Unavailable"
628 }
629
630 private static func provenanceLabel(_ provenance: SectionProvenance) -> String {
631 [
632 provenance.source,
633 provenance.provider,
634 provenance.resolver.map { "resolver=\($0)" },
635 provenance.resultSource.label.lowercased(),
636 textDateFormatter.string(from: provenance.collectedAt)
637 ]
638 .compactMap { $0 }
639 .joined(separator: " | ")
640 }
641
642 private static let textDateFormatter: DateFormatter = {
643 let formatter = DateFormatter()
644 formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
645 return formatter
646 }()
647
648 private static let csvDateFormatter: ISO8601DateFormatter = {
649 let formatter = ISO8601DateFormatter()
650 formatter.formatOptions = [.withInternetDateTime]
651 return formatter
652 }()
653
654 private static let jsonEncoder: JSONEncoder = {
655 let encoder = JSONEncoder()
656 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
657 encoder.dateEncodingStrategy = .iso8601
658 return encoder
659 }()
660
661 nonisolated private static func csvEscaped(_ value: String) -> String {
662 let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
663 return "\"\(escaped)\""
664 }
665}
666
667private extension String {
668 var nilIfEmpty: String? {
669 isEmpty ? nil : self
670 }
671}