krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

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