diff --git a/.claude/rules/gotchas.md b/.claude/rules/gotchas.md index 5d72bf39..006d824e 100644 --- a/.claude/rules/gotchas.md +++ b/.claude/rules/gotchas.md @@ -138,6 +138,24 @@ func foo(a:, b:, c:, d:, e:, f:) {} // swiftlint:enable function_parameter_count ``` +### multiple_closures_with_trailing_closure + +When a function accepts 2+ closure parameters, trailing closure syntax triggers this rule. +Use explicit argument labels for all closures: + +```swift +// BAD — trailing closure with multiple closures +withExportReport(buildStats: { ... }) { + try await export() +} + +// GOOD — explicit label +withExportReport( + buildStats: { ... }, + export: { try await export() } +) +``` + ### void_function_in_ternary False Positive SwiftLint flags `NooraUI.format()` calls in ternary operators as `void_function_in_ternary` even though they return `String`. @@ -155,6 +173,26 @@ let failIcon = useColors ? NooraUI.format(.danger("✗")) : "✗" let icon = success ? successIcon : failIcon ``` +### Actor vs Lock for Sync-Only State + +When all operations are synchronous (array append, file read), use `Lock` (NSLock wrapper) +instead of `actor`. Actor requires `await` which forces `DispatchSemaphore` bridges from sync +contexts — creating deadlock risk. See `WarningCollector`, `ManifestTracker` for the pattern. + +```swift +// BAD — actor with sync-only ops forces semaphore bridge from sync callers +actor Collector { + private var items: [String] = [] + func add(_ item: String) { items.append(item) } +} + +// GOOD — Lock is sync, no await needed +final class Collector: Sendable { + private let storage = Lock<[String]>([]) + func add(_ item: String) { storage.withLock { $0.append(item) } } +} +``` + ## Test Helpers for Codable Types ```swift diff --git a/CLAUDE.md b/CLAUDE.md index 7d9e9d9e..bb13a5fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -345,25 +345,26 @@ NooraUI.formatLink("url", useColors: true) // underlined primary ## Troubleshooting -| Problem | Solution | -| --------------------------- | ---------------------------------------------------------------------------------------------------------- | -| pkl-gen-swift not found | Build from SPM: `swift build --product pkl-gen-swift`, then `.build/debug/pkl-gen-swift` | -| PKL FrameSource change | Update ALL entry init calls in tests (EnumBridgingTests, IconsLoaderConfigTests) | -| Build fails | `swift package clean && swift build` | -| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set | -| Formatting fails | Run `./bin/mise run setup` to install tools | -| test:filter no matches | SPM converts hyphens→underscores: use `ExFig_FlutterTests` not `ExFig-FlutterTests` | -| Template errors | Check Jinja2 syntax and context variables | -| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` | -| Android pathData long | Simplify in Figma or use `--strict-path-validation` | -| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` | -| Test target won't compile | Broken test files block entire target; use `swift test --filter Target.Class` after `build` | -| Test helper JSON decode | `ContainingFrame` uses default Codable (camelCase: `nodeId`, `pageName`), NOT snake_case | -| Web entry test fails | Web entry types use `outputDirectory` field, while Android/Flutter use `output` | -| Logger concatenation err | `Logger.Message` (swift-log) requires interpolation `"\(a) \(b)"`, not concatenation `a + b` | -| Deleted variables in output | Filter `VariableValue.deletedButReferenced != true` in variable loaders AND `CodeSyntaxSyncer` | -| Jinja trailing `\n` | `{% if false %}...{% endif %}\n` renders `"\n"`, not `""` — strip whitespace-only partial template results | -| `Bundle.module` in tests | SPM test targets without declared resources don't have `Bundle.module` — use `Bundle.main` or temp bundle | +| Problem | Solution | +| --------------------------- | ------------------------------------------------------------------------------------------------------------ | +| pkl-gen-swift not found | Build from SPM: `swift build --product pkl-gen-swift`, then `.build/debug/pkl-gen-swift` | +| PKL FrameSource change | Update ALL entry init calls in tests (EnumBridgingTests, IconsLoaderConfigTests) | +| Build fails | `swift package clean && swift build` | +| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set | +| Formatting fails | Run `./bin/mise run setup` to install tools | +| test:filter no matches | SPM converts hyphens→underscores: use `ExFig_FlutterTests` not `ExFig-FlutterTests` | +| Template errors | Check Jinja2 syntax and context variables | +| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` | +| Android pathData long | Simplify in Figma or use `--strict-path-validation` | +| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` | +| Test target won't compile | Broken test files block entire target; use `swift test --filter Target.Class` after `build` | +| Test helper JSON decode | `ContainingFrame` uses default Codable (camelCase: `nodeId`, `pageName`), NOT snake_case | +| Web entry test fails | Web entry types use `outputDirectory` field, while Android/Flutter use `output` | +| Logger concatenation err | `Logger.Message` (swift-log) requires interpolation `"\(a) \(b)"`, not concatenation `a + b` | +| Deleted variables in output | Filter `VariableValue.deletedButReferenced != true` in variable loaders AND `CodeSyntaxSyncer` | +| Jinja trailing `\n` | `{% if false %}...{% endif %}\n` renders `"\n"`, not `""` — strip whitespace-only partial template results | +| `Bundle.module` in tests | SPM test targets without declared resources don't have `Bundle.module` — use `Bundle.main` or temp bundle | +| SwiftLint trailing closure | When function takes 2+ closures, use explicit label for last closure (`export: { ... }`) not trailing syntax | ## Additional Rules diff --git a/Sources/ExFigCLI/Output/FileWriter.swift b/Sources/ExFigCLI/Output/FileWriter.swift index fb0463aa..2b4a6f3f 100644 --- a/Sources/ExFigCLI/Output/FileWriter.swift +++ b/Sources/ExFigCLI/Output/FileWriter.swift @@ -160,14 +160,28 @@ final class FileWriter: Sendable { private func writeFileData(_ file: FileContents) throws { let fileURL = URL(fileURLWithPath: file.destination.url.path) + let tracker = ManifestTrackerStorage.current + if let data = file.data { + // Capture pre-state before write (zero overhead when tracker is nil) + let preState = tracker?.capturePreState(for: fileURL.path) try data.write(to: fileURL, options: .atomic) + // Record only after successful write + if let tracker, let preState { + tracker.recordWrite(path: fileURL.path, data: data, preState: preState) + } } else if let localFileURL = file.dataFile { + // Capture pre-state before write + let preState = tracker?.capturePreState(for: fileURL.path) // Remove existing file if present (copyItem fails if destination exists) if FileManager.default.fileExists(atPath: fileURL.path) { try FileManager.default.removeItem(at: fileURL) } try FileManager.default.copyItem(at: localFileURL, to: fileURL) + // Record only after successful copy + if let tracker, let preState { + tracker.recordCopy(path: fileURL.path, sourceURL: localFileURL, preState: preState) + } } else { fatalError("FileContents.data is nil. Use FileDownloader to download contents of the file.") } diff --git a/Sources/ExFigCLI/Report/AssetManifest.swift b/Sources/ExFigCLI/Report/AssetManifest.swift new file mode 100644 index 00000000..c74889b5 --- /dev/null +++ b/Sources/ExFigCLI/Report/AssetManifest.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Manifest of all files generated during an export. +struct AssetManifest: Encodable { + /// List of generated file entries. + let files: [ManifestEntry] +} + +/// A single file entry in the asset manifest. +struct ManifestEntry: Encodable { + /// Relative path to the file (relative to working directory). + let path: String + + /// What happened to this file during export. + let action: FileAction + + /// FNV-1a 64-bit content checksum (16-char hex), `nil` for deleted files or unreadable content. + let checksum: String? + + /// Type of asset (e.g., "color", "icon", "image", "typography"). + let assetType: String +} + +/// Classification of file write operations. +enum FileAction: String, Encodable { + /// File did not exist before write. + case created + /// File existed but content changed. + case modified + /// File existed with identical content. + case unchanged + /// File existed in previous report but is no longer generated. + case deleted +} diff --git a/Sources/ExFigCLI/Report/ExportReport.swift b/Sources/ExFigCLI/Report/ExportReport.swift new file mode 100644 index 00000000..679681d6 --- /dev/null +++ b/Sources/ExFigCLI/Report/ExportReport.swift @@ -0,0 +1,104 @@ +import ExFigCore +import Foundation + +/// Structured JSON report for a single export command. +/// +/// Analogous to `BatchReport` (used by `exfig batch --report`) but tailored +/// for single-command exports (`colors`, `icons`, `images`, `typography`). +struct ExportReport: Encodable { + /// Report schema version for forward compatibility. + let version: Int + + /// Command name (e.g., "colors", "icons", "images", "typography"). + let command: String + + /// Path to the PKL config file used. + let config: String + + /// Export start time (ISO8601). + let startTime: String + + /// Export end time (ISO8601). + let endTime: String + + /// Duration in seconds. + let duration: TimeInterval + + /// Whether the export succeeded. + let success: Bool + + /// Error description if export failed, `nil` on success. + let error: String? + + /// Asset counts. + let stats: ReportStats + + /// Warnings collected during export. + let warnings: [String] + + /// Asset manifest (present when file tracking is enabled). + let manifest: AssetManifest? + + /// Current report schema version. + static let currentVersion = 1 + + init( + version: Int = ExportReport.currentVersion, + command: String, + config: String, + startTime: String, + endTime: String, + duration: TimeInterval, + success: Bool, + error: String?, + stats: ReportStats, + warnings: [String], + manifest: AssetManifest? + ) { + self.version = version + self.command = command + self.config = config + self.startTime = startTime + self.endTime = endTime + self.duration = duration + self.success = success + self.error = error + self.stats = stats + self.warnings = warnings + self.manifest = manifest + } + + /// Serializes the report to pretty-printed JSON with sorted keys. + func jsonData() throws -> Data { + try JSONCodec.encodePrettySorted(self) + } +} + +/// Asset counts for a single export command report. +/// +/// Separate from `ExportStats` (which contains batch-only fields like +/// `computedNodeHashes` and `granularCacheStats` that are not `Codable`). +struct ReportStats: Encodable { + let colors: Int + let icons: Int + let images: Int + let typography: Int + + static let zero = ReportStats(colors: 0, icons: 0, images: 0, typography: 0) + + static func colors(_ count: Int) -> ReportStats { + .init(colors: count, icons: 0, images: 0, typography: 0) + } + + static func icons(_ count: Int) -> ReportStats { + .init(colors: 0, icons: count, images: 0, typography: 0) + } + + static func images(_ count: Int) -> ReportStats { + .init(colors: 0, icons: 0, images: count, typography: 0) + } + + static func typography(_ count: Int) -> ReportStats { + .init(colors: 0, icons: 0, images: 0, typography: count) + } +} diff --git a/Sources/ExFigCLI/Report/ExportReportHelper.swift b/Sources/ExFigCLI/Report/ExportReportHelper.swift new file mode 100644 index 00000000..03c83c96 --- /dev/null +++ b/Sources/ExFigCLI/Report/ExportReportHelper.swift @@ -0,0 +1,84 @@ +import Foundation + +// swiftlint:disable function_parameter_count + +/// Wraps export execution with report generation boilerplate. +/// +/// Sets up `WarningCollectorStorage` and `ManifestTrackerStorage` before export, +/// captures timing and errors, builds the report, and guarantees cleanup via `defer`. +/// +/// - Parameters: +/// - command: Export command name (e.g., "colors", "icons"). +/// - assetType: Asset type for manifest tracking (e.g., "color", "icon"). +/// - reportPath: Path to write JSON report. If `nil`, report generation is skipped entirely. +/// - configInput: PKL config path (for the report's `config` field). +/// - ui: Terminal UI for output. +/// - buildStats: Closure that converts the export count into `ReportStats`. +/// - export: The actual export operation. Returns the exported asset count. +/// - Throws: Re-throws the export error after writing the report. +func withExportReport( + command: String, + assetType: String, + reportPath: String?, + configInput: String?, + ui: TerminalUI, + buildStats: (Int) -> ReportStats, + export: () async throws -> Int +) async throws { + guard let reportPath else { + _ = try await export() + return + } + + let warningCollector = WarningCollector() + let manifestTracker = ManifestTracker(assetType: assetType) + WarningCollectorStorage.current = warningCollector + ManifestTrackerStorage.current = manifestTracker + defer { + WarningCollectorStorage.current = nil + ManifestTrackerStorage.current = nil + } + + let startTime = Date() + var exportCount = 0 + var exportError: (any Error)? + + do { + exportCount = try await export() + } catch { + exportError = error + } + + let endTime = Date() + let formatter = ISO8601DateFormatter() + + let exportReport = ExportReport( + version: ExportReport.currentVersion, + command: command, + config: configInput ?? "exfig.pkl", + startTime: formatter.string(from: startTime), + endTime: formatter.string(from: endTime), + duration: endTime.timeIntervalSince(startTime), + success: exportError == nil, + error: exportError.map { describeExportError($0) }, + stats: buildStats(exportCount), + warnings: warningCollector.getAll(), + manifest: manifestTracker.buildManifest(previousReportPath: reportPath) + ) + writeExportReport(exportReport, to: reportPath, ui: ui) + + if let error = exportError { + throw error + } +} + +// swiftlint:enable function_parameter_count + +private func describeExportError(_ error: any Error) -> String { + if let localized = error as? LocalizedError, + let description = localized.errorDescription + { + return description + } + return String(describing: error) +} diff --git a/Sources/ExFigCLI/Report/ExportReportWriter.swift b/Sources/ExFigCLI/Report/ExportReportWriter.swift new file mode 100644 index 00000000..524eb1fe --- /dev/null +++ b/Sources/ExFigCLI/Report/ExportReportWriter.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Writes an export report to disk. Failure is non-fatal — logs a warning. +/// +/// Same pattern as `Batch.swift` report writing (lines 710-716): +/// wrap write in do/catch, warn on failure, never propagate the error. +func writeExportReport(_ report: ExportReport, to path: String, ui: TerminalUI) { + let data: Data + do { + data = try report.jsonData() + } catch { + ui.warning("Failed to serialize export report: \(error.localizedDescription)") + return + } + + do { + let url = URL(fileURLWithPath: path) + let directory = url.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: url) + ui.info("Report written to: \(path)") + } catch { + ui.warning("Failed to write report to \(path): \(error.localizedDescription)") + } +} diff --git a/Sources/ExFigCLI/Report/ManifestTracker.swift b/Sources/ExFigCLI/Report/ManifestTracker.swift new file mode 100644 index 00000000..d7290004 --- /dev/null +++ b/Sources/ExFigCLI/Report/ManifestTracker.swift @@ -0,0 +1,184 @@ +import ExFigCore +import Foundation + +/// Tracks file write operations for asset manifest generation. +/// +/// Uses a two-phase API: `capturePreState` before write, `recordWrite`/`recordCopy` +/// after successful write. This ensures entries are only recorded for files that +/// were actually written, and action detection (created/modified/unchanged) uses +/// the correct pre-write filesystem state. +/// +/// Uses `Lock<[ManifestEntry]>` for thread-safe access without requiring `await`, +/// eliminating the `DispatchSemaphore` bridge that was needed with the actor version. +/// Only active when `--report` is specified — zero overhead otherwise. +/// +/// Initialized with a default `assetType` since each export command handles +/// one asset type (colors/icons/images/typography). +final class ManifestTracker: Sendable { + private let entries = Lock<[ManifestEntry]>([]) + + /// Default asset type for all recorded entries. + let defaultAssetType: String + + private let workingDirectory: String + + init(assetType: String) { + defaultAssetType = assetType + workingDirectory = FileManager.default.currentDirectoryPath + } + + /// Pre-write filesystem state for a file path. + struct PreWriteState: Sendable { + let fileExisted: Bool + let existingChecksum: String? + } + + /// Capture filesystem state before writing a file. + /// + /// Must be called BEFORE the file is written to disk, so that existing content + /// can be compared for action detection (created vs. modified vs. unchanged). + func capturePreState(for path: String) -> PreWriteState { + if let existingData = FileManager.default.contents(atPath: path) { + PreWriteState(fileExisted: true, existingChecksum: FNV1aHasher.hashToHex(existingData)) + } else { + PreWriteState(fileExisted: false, existingChecksum: nil) + } + } + + /// Record a file write operation after successful write. + /// + /// - Parameters: + /// - path: Absolute path to the written file. + /// - data: Content that was written (used for checksum). + /// - preState: Pre-write state captured via `capturePreState(for:)`. + /// - assetType: Type of asset. Defaults to tracker's `defaultAssetType`. + func recordWrite(path: String, data: Data, preState: PreWriteState, assetType: String? = nil) { + let assetType = assetType ?? defaultAssetType + let relativePath = makeRelativePath(path) + let newChecksum = FNV1aHasher.hashToHex(data) + let action = determineAction(preState: preState, newChecksum: newChecksum) + + entries.withLock { + $0.append(ManifestEntry( + path: relativePath, + action: action, + checksum: newChecksum, + assetType: assetType + )) + } + } + + /// Record a file copy operation after successful copy. + /// + /// - Parameters: + /// - path: Absolute path to the destination file. + /// - sourceURL: URL of the source file that was copied. + /// - preState: Pre-write state captured via `capturePreState(for:)`. + /// - assetType: Type of asset. Defaults to tracker's `defaultAssetType`. + func recordCopy(path: String, sourceURL: URL, preState: PreWriteState, assetType: String? = nil) { + let assetType = assetType ?? defaultAssetType + let relativePath = makeRelativePath(path) + + // Read copied file from destination (it was just written successfully) + let newChecksum: String? = if let destData = FileManager.default.contents(atPath: path) { + FNV1aHasher.hashToHex(destData) + } else if let sourceData = try? Data(contentsOf: sourceURL) { + FNV1aHasher.hashToHex(sourceData) + } else { + nil + } + + if newChecksum == nil { + WarningCollectorStorage.current?.add("Manifest: could not compute checksum for \(relativePath)") + } + + let action = determineAction(preState: preState, newChecksum: newChecksum) + + entries.withLock { + $0.append(ManifestEntry( + path: relativePath, + action: action, + checksum: newChecksum, + assetType: assetType + )) + } + } + + /// Get all recorded manifest entries. + func getAll() -> [ManifestEntry] { + entries.withLock { $0 } + } + + /// Build an `AssetManifest` from recorded entries. + /// + /// If `previousReportPath` is provided, detects deleted files by comparing + /// against the previous report's manifest. + func buildManifest(previousReportPath: String? = nil) -> AssetManifest { + var allEntries = entries.withLock { $0 } + + if let previousPath = previousReportPath, + let previousData = FileManager.default.contents(atPath: previousPath) + { + do { + let previousReport = try JSONCodec.decode(PreviousReportManifest.self, from: previousData) + let currentPaths = Set(allEntries.map(\.path)) + for previousEntry in previousReport.manifest?.files ?? [] + where !currentPaths.contains(previousEntry.path) + { + allEntries.append(ManifestEntry( + path: previousEntry.path, + action: .deleted, + checksum: nil, + assetType: previousEntry.assetType + )) + } + } catch { + let message = "Could not read previous report at \(previousPath): " + + "\(error.localizedDescription). Deleted file detection skipped." + WarningCollectorStorage.current?.add(message) + } + } + + return AssetManifest(files: allEntries) + } + + /// Determine file action based on pre-write state and new checksum. + private func determineAction(preState: PreWriteState, newChecksum: String?) -> FileAction { + if !preState.fileExisted { + .created + } else if let existingChecksum = preState.existingChecksum, let newChecksum { + existingChecksum == newChecksum ? .unchanged : .modified + } else { + .modified + } + } + + /// Make path relative to working directory captured at init time. + private func makeRelativePath(_ absolutePath: String) -> String { + if absolutePath.hasPrefix(workingDirectory + "/") { + return String(absolutePath.dropFirst(workingDirectory.count + 1)) + } + return absolutePath + } +} + +/// Lightweight Decodable for reading only the manifest from a previous report. +private struct PreviousReportManifest: Decodable { + let manifest: PreviousManifest? + + struct PreviousManifest: Decodable { + let files: [PreviousEntry] + } + + struct PreviousEntry: Decodable { + let path: String + let assetType: String + } +} + +/// Global storage for the active manifest tracker. +/// +/// Same pattern as `WarningCollectorStorage` — `nonisolated(unsafe)` static var. +enum ManifestTrackerStorage { + nonisolated(unsafe) static var current: ManifestTracker? +} diff --git a/Sources/ExFigCLI/Report/WarningCollector.swift b/Sources/ExFigCLI/Report/WarningCollector.swift new file mode 100644 index 00000000..853d63a5 --- /dev/null +++ b/Sources/ExFigCLI/Report/WarningCollector.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Collects warnings emitted during export for inclusion in the report. +/// +/// Uses `Lock<[String]>` for thread-safe access without requiring `await`, +/// eliminating the `DispatchSemaphore` bridge that was needed with the actor version. +/// Active only when `--report` is specified — otherwise `nil` and zero overhead. +/// +/// ## Usage +/// +/// ```swift +/// let collector = WarningCollector() +/// WarningCollectorStorage.current = collector +/// // ... run export (TerminalUI.warning() forwards to collector) ... +/// let warnings = collector.getAll() +/// WarningCollectorStorage.current = nil +/// ``` +final class WarningCollector: Sendable { + private let storage = Lock<[String]>([]) + + /// Add a warning message. + func add(_ message: String) { + storage.withLock { $0.append(message) } + } + + /// Get all collected warnings. + func getAll() -> [String] { + storage.withLock { $0 } + } + + /// Number of collected warnings. + var count: Int { + storage.withLock { $0.count } + } +} + +/// Global storage for the active warning collector. +/// +/// Uses a simple `nonisolated(unsafe)` static var (same pattern as +/// `ExFigCommand.terminalUI`). Set before export, cleared after. +/// Not using `@TaskLocal` to avoid nesting issues — this is only active +/// in single-command mode where there is no TaskLocal contention. +enum WarningCollectorStorage { + nonisolated(unsafe) static var current: WarningCollector? +} diff --git a/Sources/ExFigCLI/Subcommands/ExportColors.swift b/Sources/ExFigCLI/Subcommands/ExportColors.swift index 17e0abcd..f7e073c9 100644 --- a/Sources/ExFigCLI/Subcommands/ExportColors.swift +++ b/Sources/ExFigCLI/Subcommands/ExportColors.swift @@ -33,6 +33,9 @@ extension ExFigCommand { ) var filter: String? + @Option(name: .long, help: "Path to write JSON report") + var report: String? + func run() async throws { ExFigCommand.initializeTerminalUI( verbose: globalOptions.verbose, quiet: globalOptions.quiet @@ -47,7 +50,15 @@ extension ExFigCommand { ui: ui ) - _ = try await performExport(client: client, ui: ui) + try await withExportReport( + command: "colors", + assetType: "color", + reportPath: report, + configInput: options.input, + ui: ui, + buildStats: { .colors($0) }, + export: { try await performExport(client: client, ui: ui) } + ) } /// Export result for batch mode (includes file versions for deferred cache save). diff --git a/Sources/ExFigCLI/Subcommands/ExportIcons.swift b/Sources/ExFigCLI/Subcommands/ExportIcons.swift index 4b1d66a6..a20f3738 100644 --- a/Sources/ExFigCLI/Subcommands/ExportIcons.swift +++ b/Sources/ExFigCLI/Subcommands/ExportIcons.swift @@ -36,6 +36,9 @@ extension ExFigCommand { """) var strictPathValidation: Bool = false + @Option(name: .long, help: "Path to write JSON report") + var report: String? + func run() async throws { ExFigCommand.initializeTerminalUI(verbose: globalOptions.verbose, quiet: globalOptions.quiet) ExFigCommand.checkSchemaVersionIfNeeded() @@ -48,7 +51,15 @@ extension ExFigCommand { ui: ui ) - _ = try await performExport(client: client, ui: ui) + try await withExportReport( + command: "icons", + assetType: "icon", + reportPath: report, + configInput: options.input, + ui: ui, + buildStats: { .icons($0) }, + export: { try await performExport(client: client, ui: ui) } + ) } /// Result of icons export for batch mode integration. diff --git a/Sources/ExFigCLI/Subcommands/ExportImages.swift b/Sources/ExFigCLI/Subcommands/ExportImages.swift index bf721d39..7ff739d1 100644 --- a/Sources/ExFigCLI/Subcommands/ExportImages.swift +++ b/Sources/ExFigCLI/Subcommands/ExportImages.swift @@ -33,6 +33,9 @@ extension ExFigCommand { ) var filter: String? + @Option(name: .long, help: "Path to write JSON report") + var report: String? + func run() async throws { ExFigCommand.initializeTerminalUI( verbose: globalOptions.verbose, quiet: globalOptions.quiet @@ -47,7 +50,15 @@ extension ExFigCommand { ui: ui ) - _ = try await performExport(client: client, ui: ui) + try await withExportReport( + command: "images", + assetType: "image", + reportPath: report, + configInput: options.input, + ui: ui, + buildStats: { .images($0) }, + export: { try await performExport(client: client, ui: ui) } + ) } /// Result of images export for batch mode integration. diff --git a/Sources/ExFigCLI/Subcommands/ExportTypography.swift b/Sources/ExFigCLI/Subcommands/ExportTypography.swift index faa4d6b4..fdd1ea0e 100644 --- a/Sources/ExFigCLI/Subcommands/ExportTypography.swift +++ b/Sources/ExFigCLI/Subcommands/ExportTypography.swift @@ -24,6 +24,9 @@ extension ExFigCommand { @OptionGroup var faultToleranceOptions: FaultToleranceOptions + @Option(name: .long, help: "Path to write JSON report") + var report: String? + func run() async throws { ExFigCommand.initializeTerminalUI(verbose: globalOptions.verbose, quiet: globalOptions.quiet) ExFigCommand.checkSchemaVersionIfNeeded() @@ -36,7 +39,15 @@ extension ExFigCommand { ui: ui ) - _ = try await performExport(client: client, ui: ui) + try await withExportReport( + command: "typography", + assetType: "typography", + reportPath: report, + configInput: options.input, + ui: ui, + buildStats: { .typography($0) }, + export: { try await performExport(client: client, ui: ui) } + ) } /// Export result for batch mode (includes file versions for deferred cache save). diff --git a/Sources/ExFigCLI/TerminalUI/TerminalUI.swift b/Sources/ExFigCLI/TerminalUI/TerminalUI.swift index 64af269e..f9c94e28 100644 --- a/Sources/ExFigCLI/TerminalUI/TerminalUI.swift +++ b/Sources/ExFigCLI/TerminalUI/TerminalUI.swift @@ -47,6 +47,10 @@ final class TerminalUI: Sendable { /// Print a warning message (handles multi-line properly) func warning(_ message: String) { + // Forward to warning collector when active (--report mode) + // Direct sync call — no semaphore needed (Lock-based collector) + WarningCollectorStorage.current?.add(message) + // In batch mode, queue for coordinated output to prevent race conditions if let progressView = BatchSharedState.current?.progressView { let formatted = formatWarningForQueue(message) diff --git a/Tests/ExFigTests/Report/DeletedFileDetectionTests.swift b/Tests/ExFigTests/Report/DeletedFileDetectionTests.swift new file mode 100644 index 00000000..91cb4a30 --- /dev/null +++ b/Tests/ExFigTests/Report/DeletedFileDetectionTests.swift @@ -0,0 +1,163 @@ +@testable import ExFigCLI +import Foundation +import XCTest + +final class DeletedFileDetectionTests: XCTestCase { + private var tempDirectory: URL! + + override func setUp() { + super.setUp() + tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("DeletedFileDetectionTests-\(UUID().uuidString)") + // swiftlint:disable:next force_try + try! FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempDirectory) + super.tearDown() + } + + // MARK: - Deleted Detection + + func testDeletedFileDetectedFromPreviousReport() throws { + // Use absolute paths in previous report that match what ManifestTracker would generate. + // ManifestTracker.makeRelativePath() strips CWD prefix, so use paths + // that don't match CWD to get predictable absolute paths in entries. + let colorsPath = tempDirectory.appendingPathComponent("Colors.swift").path + let oldColorsPath = tempDirectory.appendingPathComponent("OldColors.swift").path + + // Create a previous report with two files + let previousReport: [String: Any] = [ + "version": 1, + "command": "colors", + "config": "exfig.pkl", + "startTime": "2024-01-01T00:00:00Z", + "endTime": "2024-01-01T00:00:01Z", + "duration": 1.0, + "success": true, + "stats": ["colors": 2, "icons": 0, "images": 0, "typography": 0], + "warnings": [] as [String], + "manifest": [ + "files": [ + ["path": colorsPath, "action": "created", "checksum": "abc123", "assetType": "color"], + ["path": oldColorsPath, "action": "created", "checksum": "def456", "assetType": "color"], + ], + ] as [String: Any], + ] + + let previousReportPath = tempDirectory.appendingPathComponent("report.json").path + let previousData = try JSONSerialization.data(withJSONObject: previousReport) + try previousData.write(to: URL(fileURLWithPath: previousReportPath)) + + // New export only generates Colors.swift (OldColors.swift was removed from Figma) + let tracker = ManifestTracker(assetType: "color") + let preState = tracker.capturePreState(for: colorsPath) + tracker.recordWrite(path: colorsPath, data: Data("new colors".utf8), preState: preState) + + let manifest = tracker.buildManifest(previousReportPath: previousReportPath) + + // Should have 2 entries: Colors.swift (created) + OldColors.swift (deleted) + XCTAssertEqual(manifest.files.count, 2) + + let deletedEntry = manifest.files.first { $0.path.hasSuffix("OldColors.swift") } + XCTAssertNotNil(deletedEntry) + XCTAssertEqual(deletedEntry?.action, .deleted) + XCTAssertNil(deletedEntry?.checksum) + XCTAssertEqual(deletedEntry?.assetType, "color") + } + + // MARK: - No Previous Report + + func testNoPreviousReportNoDeletedFiles() { + let tracker = ManifestTracker(assetType: "icon") + let filePath = tempDirectory.appendingPathComponent("Icons.swift").path + let preState = tracker.capturePreState(for: filePath) + tracker.recordWrite(path: filePath, data: Data("icons".utf8), preState: preState) + + let manifest = tracker.buildManifest(previousReportPath: nil) + XCTAssertEqual(manifest.files.count, 1) + XCTAssertTrue(manifest.files.allSatisfy { $0.action != .deleted }) + } + + // MARK: - Non-existent Previous Report Path + + func testNonExistentPreviousReportPath() { + let tracker = ManifestTracker(assetType: "color") + let filePath = tempDirectory.appendingPathComponent("Colors.swift").path + let preState = tracker.capturePreState(for: filePath) + tracker.recordWrite(path: filePath, data: Data("colors".utf8), preState: preState) + + let manifest = tracker.buildManifest(previousReportPath: "/nonexistent/report.json") + XCTAssertEqual(manifest.files.count, 1) + XCTAssertTrue(manifest.files.allSatisfy { $0.action != .deleted }) + } + + // MARK: - All Files Still Present + + func testAllFilesPresentNoDeletedEntries() throws { + // Use absolute paths matching what ManifestTracker produces for temp directory paths. + let colorsPath = tempDirectory.appendingPathComponent("Colors.swift").path + + let previousReport: [String: Any] = [ + "version": 1, + "command": "colors", + "config": "exfig.pkl", + "startTime": "2024-01-01T00:00:00Z", + "endTime": "2024-01-01T00:00:01Z", + "duration": 1.0, + "success": true, + "stats": ["colors": 1, "icons": 0, "images": 0, "typography": 0], + "warnings": [] as [String], + "manifest": [ + "files": [ + ["path": colorsPath, "action": "created", "checksum": "abc123", "assetType": "color"], + ], + ] as [String: Any], + ] + + let previousReportPath = tempDirectory.appendingPathComponent("report.json").path + let previousData = try JSONSerialization.data(withJSONObject: previousReport) + try previousData.write(to: URL(fileURLWithPath: previousReportPath)) + + // New export generates the same file path + let tracker = ManifestTracker(assetType: "color") + let preState = tracker.capturePreState(for: colorsPath) + tracker.recordWrite(path: colorsPath, data: Data("colors".utf8), preState: preState) + + let manifest = tracker.buildManifest(previousReportPath: previousReportPath) + + // Paths match — no deleted entries + let deletedFiles = manifest.files.filter { $0.action == .deleted } + XCTAssertTrue(deletedFiles.isEmpty, "Expected no deleted entries when all files are still present") + } + + // MARK: - Previous Report Without Manifest + + func testPreviousReportWithoutManifest() throws { + let previousReport: [String: Any] = [ + "version": 1, + "command": "colors", + "config": "exfig.pkl", + "startTime": "2024-01-01T00:00:00Z", + "endTime": "2024-01-01T00:00:01Z", + "duration": 1.0, + "success": true, + "stats": ["colors": 1, "icons": 0, "images": 0, "typography": 0], + "warnings": [] as [String], + ] + + let previousReportPath = tempDirectory.appendingPathComponent("report.json").path + let previousData = try JSONSerialization.data(withJSONObject: previousReport) + try previousData.write(to: URL(fileURLWithPath: previousReportPath)) + + let tracker = ManifestTracker(assetType: "color") + let filePath = tempDirectory.appendingPathComponent("Colors.swift").path + let preState = tracker.capturePreState(for: filePath) + tracker.recordWrite(path: filePath, data: Data("colors".utf8), preState: preState) + + let manifest = tracker.buildManifest(previousReportPath: previousReportPath) + // No deleted entries when previous report has no manifest + XCTAssertTrue(manifest.files.allSatisfy { $0.action != .deleted }) + } +} diff --git a/Tests/ExFigTests/Report/ExportReportIntegrationTests.swift b/Tests/ExFigTests/Report/ExportReportIntegrationTests.swift new file mode 100644 index 00000000..0367d391 --- /dev/null +++ b/Tests/ExFigTests/Report/ExportReportIntegrationTests.swift @@ -0,0 +1,152 @@ +@testable import ExFigCLI +import Foundation +import XCTest + +final class ExportReportIntegrationTests: XCTestCase { + private var tempDirectory: URL! + + override func setUp() { + super.setUp() + tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("ExportReportIntegrationTests-\(UUID().uuidString)") + // swiftlint:disable:next force_try + try! FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempDirectory) + super.tearDown() + } + + // MARK: - 6.1 Valid JSON with version, command, stats, timestamps + + func testReportProducesValidJSON() throws { + let reportPath = tempDirectory.appendingPathComponent("report.json").path + let ui = TerminalUI(outputMode: .quiet) + + let report = ExportReport( + version: ExportReport.currentVersion, + command: "colors", + config: "exfig.pkl", + startTime: ISO8601DateFormatter().string(from: Date()), + endTime: ISO8601DateFormatter().string(from: Date()), + duration: 1.5, + success: true, + error: nil, + stats: ReportStats(colors: 10, icons: 0, images: 0, typography: 0), + warnings: [], + manifest: AssetManifest(files: []) + ) + + writeExportReport(report, to: reportPath, ui: ui) + + // Verify file exists + XCTAssertTrue(FileManager.default.fileExists(atPath: reportPath)) + + // Verify valid JSON + let data = try Data(contentsOf: URL(fileURLWithPath: reportPath)) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + XCTAssertNotNil(json) + XCTAssertEqual(json?["version"] as? Int, 1) + XCTAssertEqual(json?["command"] as? String, "colors") + XCTAssertNotNil(json?["startTime"]) + XCTAssertNotNil(json?["endTime"]) + XCTAssertEqual(json?["duration"] as? Double, 1.5) + + let stats = json?["stats"] as? [String: Any] + XCTAssertEqual(stats?["colors"] as? Int, 10) + } + + // MARK: - 6.2 Export failure writes report with success: false + + func testFailedExportStillWritesReport() throws { + let reportPath = tempDirectory.appendingPathComponent("failure.json").path + let ui = TerminalUI(outputMode: .quiet) + + let report = ExportReport( + version: ExportReport.currentVersion, + command: "icons", + config: "exfig.pkl", + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-01T00:00:01Z", + duration: 1.0, + success: false, + error: "FIGMA_PERSONAL_TOKEN not set", + stats: ReportStats.zero, + warnings: ["Token was not set"], + manifest: nil + ) + + writeExportReport(report, to: reportPath, ui: ui) + + XCTAssertTrue(FileManager.default.fileExists(atPath: reportPath)) + + let data = try Data(contentsOf: URL(fileURLWithPath: reportPath)) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + XCTAssertEqual(json?["success"] as? Bool, false) + XCTAssertEqual(json?["error"] as? String, "FIGMA_PERSONAL_TOKEN not set") + } + + // MARK: - 6.3 Report write failure does not fail the export + + func testReportWriteFailureDoesNotThrow() { + let ui = TerminalUI(outputMode: .quiet) + let invalidPath = "/nonexistent/directory/report.json" + + let report = ExportReport( + version: ExportReport.currentVersion, + command: "colors", + config: "exfig.pkl", + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-01T00:00:01Z", + duration: 1.0, + success: true, + error: nil, + stats: ReportStats(colors: 5, icons: 0, images: 0, typography: 0), + warnings: [], + manifest: nil + ) + + // Should NOT throw — writeExportReport catches errors internally + writeExportReport(report, to: invalidPath, ui: ui) + + // Verify no file was created + XCTAssertFalse(FileManager.default.fileExists(atPath: invalidPath)) + } + + // MARK: - 6.4 Zero-file export produces report with empty manifest + + func testZeroFileExportEmptyManifest() throws { + let reportPath = tempDirectory.appendingPathComponent("empty.json").path + let ui = TerminalUI(outputMode: .quiet) + + let report = ExportReport( + version: ExportReport.currentVersion, + command: "images", + config: "exfig.pkl", + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-01T00:00:01Z", + duration: 0.5, + success: true, + error: nil, + stats: ReportStats.zero, + warnings: [], + manifest: AssetManifest(files: []) + ) + + writeExportReport(report, to: reportPath, ui: ui) + + let data = try Data(contentsOf: URL(fileURLWithPath: reportPath)) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + let manifest = json?["manifest"] as? [String: Any] + let files = manifest?["files"] as? [Any] + XCTAssertEqual(files?.count, 0) + + XCTAssertEqual(json?["stats"] as? [String: Int], [ + "colors": 0, "icons": 0, "images": 0, "typography": 0, + ]) + } +} diff --git a/Tests/ExFigTests/Report/ExportReportTests.swift b/Tests/ExFigTests/Report/ExportReportTests.swift new file mode 100644 index 00000000..f4ee795c --- /dev/null +++ b/Tests/ExFigTests/Report/ExportReportTests.swift @@ -0,0 +1,217 @@ +@testable import ExFigCLI +import Foundation +import XCTest + +final class ExportReportTests: XCTestCase { + // MARK: - Success Case + + func testSuccessReportJSON() throws { + let report = ExportReport( + version: ExportReport.currentVersion, + command: "colors", + config: "exfig.pkl", + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-01T00:00:05Z", + duration: 5.0, + success: true, + error: nil, + stats: ReportStats(colors: 42, icons: 0, images: 0, typography: 0), + warnings: [], + manifest: nil + ) + + let data = try report.jsonData() + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + // swiftlint:disable:next force_unwrapping + XCTAssertEqual(json?["version"] as? Int, 1) + XCTAssertEqual(json?["command"] as? String, "colors") + XCTAssertEqual(json?["config"] as? String, "exfig.pkl") + XCTAssertEqual(json?["startTime"] as? String, "2024-01-01T00:00:00Z") + XCTAssertEqual(json?["endTime"] as? String, "2024-01-01T00:00:05Z") + XCTAssertEqual(json?["duration"] as? Double, 5.0) + XCTAssertEqual(json?["success"] as? Bool, true) + XCTAssertNil(json?["error"] as? String) + + let stats = json?["stats"] as? [String: Any] + XCTAssertEqual(stats?["colors"] as? Int, 42) + XCTAssertEqual(stats?["icons"] as? Int, 0) + XCTAssertEqual(stats?["images"] as? Int, 0) + XCTAssertEqual(stats?["typography"] as? Int, 0) + } + + // MARK: - Failure Case + + func testFailureReportJSON() throws { + let report = ExportReport( + version: ExportReport.currentVersion, + command: "icons", + config: "exfig.pkl", + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-01T00:00:01Z", + duration: 1.0, + success: false, + error: "FIGMA_PERSONAL_TOKEN not set", + stats: ReportStats.zero, + warnings: [], + manifest: nil + ) + + let data = try report.jsonData() + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + XCTAssertEqual(json?["success"] as? Bool, false) + XCTAssertEqual(json?["error"] as? String, "FIGMA_PERSONAL_TOKEN not set") + } + + // MARK: - Empty Warnings + + func testEmptyWarningsArray() throws { + let report = ExportReport( + version: ExportReport.currentVersion, + command: "images", + config: "exfig.pkl", + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-01T00:00:02Z", + duration: 2.0, + success: true, + error: nil, + stats: ReportStats.zero, + warnings: [], + manifest: nil + ) + + let data = try report.jsonData() + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + let warnings = json?["warnings"] as? [String] + XCTAssertEqual(warnings, []) + } + + // MARK: - Warnings Populated + + func testWarningsPopulated() throws { + let report = ExportReport( + version: ExportReport.currentVersion, + command: "colors", + config: "exfig.pkl", + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-01T00:00:03Z", + duration: 3.0, + success: true, + error: nil, + stats: ReportStats(colors: 10, icons: 0, images: 0, typography: 0), + warnings: ["Warning 1", "Warning 2"], + manifest: nil + ) + + let data = try report.jsonData() + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + let warnings = json?["warnings"] as? [String] + XCTAssertEqual(warnings, ["Warning 1", "Warning 2"]) + } + + // MARK: - Version Field + + func testVersionField() throws { + XCTAssertEqual(ExportReport.currentVersion, 1) + + let report = ExportReport( + version: ExportReport.currentVersion, + command: "typography", + config: "exfig.pkl", + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-01T00:00:01Z", + duration: 1.0, + success: true, + error: nil, + stats: ReportStats.zero, + warnings: [], + manifest: nil + ) + + let data = try report.jsonData() + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + XCTAssertEqual(json?["version"] as? Int, 1) + } + + // MARK: - ReportStats Zero + + func testReportStatsZero() { + let stats = ReportStats.zero + XCTAssertEqual(stats.colors, 0) + XCTAssertEqual(stats.icons, 0) + XCTAssertEqual(stats.images, 0) + XCTAssertEqual(stats.typography, 0) + } + + // MARK: - Report with Manifest + + func testReportWithManifest() throws { + let manifest = AssetManifest(files: [ + ManifestEntry(path: "Colors.swift", action: .created, checksum: "abc123", assetType: "color"), + ManifestEntry(path: "OldFile.swift", action: .deleted, checksum: nil, assetType: "color"), + ]) + + let report = ExportReport( + version: ExportReport.currentVersion, + command: "colors", + config: "exfig.pkl", + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-01T00:00:01Z", + duration: 1.0, + success: true, + error: nil, + stats: ReportStats(colors: 1, icons: 0, images: 0, typography: 0), + warnings: [], + manifest: manifest + ) + + let data = try report.jsonData() + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + let manifestJSON = json?["manifest"] as? [String: Any] + let files = manifestJSON?["files"] as? [[String: Any]] + XCTAssertEqual(files?.count, 2) + + let createdFile = files?.first + XCTAssertEqual(createdFile?["path"] as? String, "Colors.swift") + XCTAssertEqual(createdFile?["action"] as? String, "created") + XCTAssertEqual(createdFile?["checksum"] as? String, "abc123") + XCTAssertEqual(createdFile?["assetType"] as? String, "color") + + let deletedFile = files?.last + XCTAssertEqual(deletedFile?["action"] as? String, "deleted") + XCTAssertNil(deletedFile?["checksum"] as? String) + } + + // MARK: - Null Manifest + + func testNullManifest() throws { + let report = ExportReport( + version: ExportReport.currentVersion, + command: "icons", + config: "exfig.pkl", + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-01T00:00:01Z", + duration: 1.0, + success: true, + error: nil, + stats: ReportStats.zero, + warnings: [], + manifest: nil + ) + + let data = try report.jsonData() + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + // When manifest is nil, key is either absent or null — both valid + let hasManifestKey = json?.keys.contains("manifest") ?? false + if hasManifestKey { + XCTAssertTrue(json?["manifest"] is NSNull) + } + // No manifest entry in files either way + XCTAssertNil(json?["manifest"] as? [String: Any]) + } +} diff --git a/Tests/ExFigTests/Report/ManifestTrackerTests.swift b/Tests/ExFigTests/Report/ManifestTrackerTests.swift new file mode 100644 index 00000000..cb5dedca --- /dev/null +++ b/Tests/ExFigTests/Report/ManifestTrackerTests.swift @@ -0,0 +1,313 @@ +@testable import ExFigCLI +@testable import ExFigCore +import Foundation +import XCTest + +final class ManifestTrackerTests: XCTestCase { + private var tempDirectory: URL! + + override func setUp() { + super.setUp() + tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("ManifestTrackerTests-\(UUID().uuidString)") + // swiftlint:disable:next force_try + try! FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + ManifestTrackerStorage.current = nil + } + + override func tearDown() { + ManifestTrackerStorage.current = nil + try? FileManager.default.removeItem(at: tempDirectory) + super.tearDown() + } + + // MARK: - Created Detection + + func testRecordWriteCreatedAction() throws { + let tracker = ManifestTracker(assetType: "color") + let filePath = tempDirectory.appendingPathComponent("new_file.swift").path + let data = Data("let colors = []".utf8) + + // File does not exist yet + XCTAssertFalse(FileManager.default.fileExists(atPath: filePath)) + + let preState = tracker.capturePreState(for: filePath) + // Simulate write + // swiftlint:disable:next force_try + try data.write(to: URL(fileURLWithPath: filePath)) + tracker.recordWrite(path: filePath, data: data, preState: preState) + + let entries = tracker.getAll() + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries[0].action, .created) + XCTAssertEqual(entries[0].assetType, "color") + XCTAssertNotNil(entries[0].checksum) + XCTAssertEqual(entries[0].checksum?.count, 16) // FNV-1a 16-char hex + } + + // MARK: - Modified Detection + + func testRecordWriteModifiedAction() throws { + let tracker = ManifestTracker(assetType: "icon") + let filePath = tempDirectory.appendingPathComponent("existing.swift").path + + // Create existing file with different content + try Data("old content".utf8).write(to: URL(fileURLWithPath: filePath)) + + let preState = tracker.capturePreState(for: filePath) + let newData = Data("new content".utf8) + try newData.write(to: URL(fileURLWithPath: filePath)) + tracker.recordWrite(path: filePath, data: newData, preState: preState) + + let entries = tracker.getAll() + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries[0].action, .modified) + } + + // MARK: - Unchanged Detection + + func testRecordWriteUnchangedAction() throws { + let tracker = ManifestTracker(assetType: "image") + let filePath = tempDirectory.appendingPathComponent("same.swift").path + let content = Data("same content".utf8) + + // Create existing file with same content + try content.write(to: URL(fileURLWithPath: filePath)) + + let preState = tracker.capturePreState(for: filePath) + try content.write(to: URL(fileURLWithPath: filePath)) + tracker.recordWrite(path: filePath, data: content, preState: preState) + + let entries = tracker.getAll() + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries[0].action, .unchanged) + } + + // MARK: - Default Asset Type + + func testDefaultAssetType() { + let tracker = ManifestTracker(assetType: "typography") + let filePath = tempDirectory.appendingPathComponent("fonts.swift").path + let data = Data("fonts".utf8) + + let preState = tracker.capturePreState(for: filePath) + tracker.recordWrite(path: filePath, data: data, preState: preState) + + let entries = tracker.getAll() + XCTAssertEqual(entries[0].assetType, "typography") + } + + // MARK: - Asset Type Override + + func testAssetTypeOverride() { + let tracker = ManifestTracker(assetType: "color") + let filePath = tempDirectory.appendingPathComponent("icon.svg").path + let data = Data("".utf8) + + let preState = tracker.capturePreState(for: filePath) + tracker.recordWrite(path: filePath, data: data, preState: preState, assetType: "icon") + + let entries = tracker.getAll() + XCTAssertEqual(entries[0].assetType, "icon") + } + + // MARK: - Build Manifest + + func testBuildManifest() { + let tracker = ManifestTracker(assetType: "color") + let filePath = tempDirectory.appendingPathComponent("colors.swift").path + let data = Data("colors".utf8) + + let preState = tracker.capturePreState(for: filePath) + tracker.recordWrite(path: filePath, data: data, preState: preState) + + let manifest = tracker.buildManifest() + XCTAssertEqual(manifest.files.count, 1) + } + + // MARK: - Storage + + func testManifestTrackerStorage() { + XCTAssertNil(ManifestTrackerStorage.current) + + let tracker = ManifestTracker(assetType: "icon") + ManifestTrackerStorage.current = tracker + XCTAssertNotNil(ManifestTrackerStorage.current) + + ManifestTrackerStorage.current = nil + XCTAssertNil(ManifestTrackerStorage.current) + } + + // MARK: - Checksum Consistency + + func testChecksumConsistency() { + let tracker = ManifestTracker(assetType: "color") + let data = Data("consistent content".utf8) + + let path1 = tempDirectory.appendingPathComponent("file1.swift").path + let path2 = tempDirectory.appendingPathComponent("file2.swift").path + + let preState1 = tracker.capturePreState(for: path1) + tracker.recordWrite(path: path1, data: data, preState: preState1) + + let preState2 = tracker.capturePreState(for: path2) + tracker.recordWrite(path: path2, data: data, preState: preState2) + + let entries = tracker.getAll() + XCTAssertEqual(entries[0].checksum, entries[1].checksum) + } + + // MARK: - RecordCopy Tests + + func testRecordCopyCreated() throws { + let tracker = ManifestTracker(assetType: "image") + let sourceURL = tempDirectory.appendingPathComponent("source.png") + let destPath = tempDirectory.appendingPathComponent("dest.png").path + let content = Data("image data".utf8) + + try content.write(to: sourceURL) + + let preState = tracker.capturePreState(for: destPath) + XCTAssertFalse(preState.fileExisted) + + // Simulate copy + try FileManager.default.copyItem(at: sourceURL, to: URL(fileURLWithPath: destPath)) + tracker.recordCopy(path: destPath, sourceURL: sourceURL, preState: preState) + + let entries = tracker.getAll() + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries[0].action, .created) + XCTAssertNotNil(entries[0].checksum) + } + + func testRecordCopyModified() throws { + let tracker = ManifestTracker(assetType: "image") + let sourceURL = tempDirectory.appendingPathComponent("source.png") + let destPath = tempDirectory.appendingPathComponent("dest.png").path + + // Create existing destination with different content + try Data("old image".utf8).write(to: URL(fileURLWithPath: destPath)) + + let preState = tracker.capturePreState(for: destPath) + XCTAssertTrue(preState.fileExisted) + + // Write new source and copy + let newContent = Data("new image".utf8) + try newContent.write(to: sourceURL) + try FileManager.default.removeItem(atPath: destPath) + try FileManager.default.copyItem(at: sourceURL, to: URL(fileURLWithPath: destPath)) + tracker.recordCopy(path: destPath, sourceURL: sourceURL, preState: preState) + + let entries = tracker.getAll() + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries[0].action, .modified) + } + + func testRecordCopyUnchanged() throws { + let tracker = ManifestTracker(assetType: "image") + let sourceURL = tempDirectory.appendingPathComponent("source.png") + let destPath = tempDirectory.appendingPathComponent("dest.png").path + let content = Data("same image".utf8) + + // Create both with same content + try content.write(to: sourceURL) + try content.write(to: URL(fileURLWithPath: destPath)) + + let preState = tracker.capturePreState(for: destPath) + XCTAssertTrue(preState.fileExisted) + + // Re-copy same content + try FileManager.default.removeItem(atPath: destPath) + try FileManager.default.copyItem(at: sourceURL, to: URL(fileURLWithPath: destPath)) + tracker.recordCopy(path: destPath, sourceURL: sourceURL, preState: preState) + + let entries = tracker.getAll() + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries[0].action, .unchanged) + } + + func testRecordCopyWithUnreadableSource() { + let tracker = ManifestTracker(assetType: "image") + let sourceURL = URL(fileURLWithPath: "/nonexistent/source.png") + let destPath = tempDirectory.appendingPathComponent("dest.png").path + + let preState = tracker.capturePreState(for: destPath) + + // Destination doesn't exist, source is unreadable — still records entry + tracker.recordCopy(path: destPath, sourceURL: sourceURL, preState: preState) + + let entries = tracker.getAll() + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries[0].action, .created) + // Checksum is nil when both source and destination are unreadable + XCTAssertNil(entries[0].checksum) + } + + // MARK: - Two-Phase Prevents Phantom Entries + + func testNoEntryRecordedOnFailedWrite() { + let tracker = ManifestTracker(assetType: "color") + let filePath = tempDirectory.appendingPathComponent("colors.swift").path + let data = Data("colors".utf8) + + // Capture pre-state + let preState = tracker.capturePreState(for: filePath) + + // Simulate failed write — don't call recordWrite + // Entries should be empty + let entries = tracker.getAll() + XCTAssertTrue(entries.isEmpty) + + // Now use preState to verify it captured correctly + XCTAssertFalse(preState.fileExisted) + XCTAssertNil(preState.existingChecksum) + + // If write eventually succeeds, we can still record + tracker.recordWrite(path: filePath, data: data, preState: preState) + XCTAssertEqual(tracker.getAll().count, 1) + } +} + +// MARK: - AssetManifest JSON Tests + +final class AssetManifestTests: XCTestCase { + func testAssetManifestJSONSerialization() throws { + let manifest = AssetManifest(files: [ + ManifestEntry(path: "Colors.swift", action: .created, checksum: "abcdef0123456789", assetType: "color"), + ManifestEntry(path: "Icons.swift", action: .modified, checksum: "1234567890abcdef", assetType: "icon"), + ManifestEntry(path: "Old.swift", action: .deleted, checksum: nil, assetType: "color"), + ManifestEntry(path: "Same.swift", action: .unchanged, checksum: "fedcba9876543210", assetType: "image"), + ]) + + let data = try JSONCodec.encode(manifest) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let files = json?["files"] as? [[String: Any]] + + XCTAssertEqual(files?.count, 4) + + // Verify created + XCTAssertEqual(files?[0]["action"] as? String, "created") + XCTAssertEqual(files?[0]["path"] as? String, "Colors.swift") + XCTAssertEqual(files?[0]["checksum"] as? String, "abcdef0123456789") + + // Verify deleted has null checksum + XCTAssertEqual(files?[2]["action"] as? String, "deleted") + XCTAssertNil(files?[2]["checksum"] as? String) + } + + func testEmptyManifest() throws { + let manifest = AssetManifest(files: []) + let data = try JSONCodec.encode(manifest) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let files = json?["files"] as? [Any] + + XCTAssertEqual(files?.count, 0) + } + + func testFileActionRawValues() { + XCTAssertEqual(FileAction.created.rawValue, "created") + XCTAssertEqual(FileAction.modified.rawValue, "modified") + XCTAssertEqual(FileAction.unchanged.rawValue, "unchanged") + XCTAssertEqual(FileAction.deleted.rawValue, "deleted") + } +} diff --git a/Tests/ExFigTests/Report/WarningCollectorTests.swift b/Tests/ExFigTests/Report/WarningCollectorTests.swift new file mode 100644 index 00000000..38c08cb1 --- /dev/null +++ b/Tests/ExFigTests/Report/WarningCollectorTests.swift @@ -0,0 +1,72 @@ +@testable import ExFigCLI +import XCTest + +final class WarningCollectorTests: XCTestCase { + // MARK: - Empty State + + func testEmptyCollector() { + let collector = WarningCollector() + let warnings = collector.getAll() + XCTAssertTrue(warnings.isEmpty) + XCTAssertEqual(collector.count, 0) + } + + // MARK: - Add Warnings + + func testAddSingleWarning() { + let collector = WarningCollector() + collector.add("Test warning") + let warnings = collector.getAll() + XCTAssertEqual(warnings, ["Test warning"]) + } + + func testAddMultipleWarnings() { + let collector = WarningCollector() + collector.add("Warning 1") + collector.add("Warning 2") + collector.add("Warning 3") + + let warnings = collector.getAll() + XCTAssertEqual(warnings, ["Warning 1", "Warning 2", "Warning 3"]) + XCTAssertEqual(collector.count, 3) + } + + // MARK: - Ordering + + func testWarningsPreserveOrder() { + let collector = WarningCollector() + for i in 1 ... 5 { + collector.add("Warning \(i)") + } + + let warnings = collector.getAll() + XCTAssertEqual(warnings, ["Warning 1", "Warning 2", "Warning 3", "Warning 4", "Warning 5"]) + } + + // MARK: - Storage + + func testStorageSetAndClear() { + XCTAssertNil(WarningCollectorStorage.current) + + let collector = WarningCollector() + WarningCollectorStorage.current = collector + XCTAssertNotNil(WarningCollectorStorage.current) + + WarningCollectorStorage.current = nil + XCTAssertNil(WarningCollectorStorage.current) + } + + // MARK: - TerminalUI Integration + + func testTerminalUIWarningForwardsToCollector() { + let collector = WarningCollector() + WarningCollectorStorage.current = collector + defer { WarningCollectorStorage.current = nil } + + let ui = TerminalUI(outputMode: .quiet) + ui.warning("forwarded warning") + + let warnings = collector.getAll() + XCTAssertEqual(warnings, ["forwarded warning"]) + } +} diff --git a/Tests/ExFigTests/Report/WithExportReportTests.swift b/Tests/ExFigTests/Report/WithExportReportTests.swift new file mode 100644 index 00000000..b13ec478 --- /dev/null +++ b/Tests/ExFigTests/Report/WithExportReportTests.swift @@ -0,0 +1,182 @@ +@testable import ExFigCLI +import Foundation +import XCTest + +final class WithExportReportTests: XCTestCase { + private var tempDirectory: URL! + + override func setUp() { + super.setUp() + tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("WithExportReportTests-\(UUID().uuidString)") + // swiftlint:disable:next force_try + try! FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + WarningCollectorStorage.current = nil + ManifestTrackerStorage.current = nil + } + + override func tearDown() { + WarningCollectorStorage.current = nil + ManifestTrackerStorage.current = nil + try? FileManager.default.removeItem(at: tempDirectory) + super.tearDown() + } + + // MARK: - Nil Report Path Skips Report + + func testNilReportPathSkipsReportGeneration() async throws { + let ui = TerminalUI(outputMode: .quiet) + var exportCalled = false + + try await withExportReport( + command: "colors", + assetType: "color", + reportPath: nil, + configInput: "exfig.pkl", + ui: ui, + buildStats: { .colors($0) }, + export: { + exportCalled = true + return 5 + } + ) + + XCTAssertTrue(exportCalled) + // No storage should be set when reportPath is nil + XCTAssertNil(WarningCollectorStorage.current) + XCTAssertNil(ManifestTrackerStorage.current) + } + + // MARK: - Report Written On Success + + func testReportWrittenOnSuccess() async throws { + let reportPath = tempDirectory.appendingPathComponent("success.json").path + let ui = TerminalUI(outputMode: .quiet) + + try await withExportReport( + command: "colors", + assetType: "color", + reportPath: reportPath, + configInput: "test.pkl", + ui: ui, + buildStats: { .colors($0) }, + export: { 42 } + ) + + XCTAssertTrue(FileManager.default.fileExists(atPath: reportPath)) + + let data = try Data(contentsOf: URL(fileURLWithPath: reportPath)) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + XCTAssertEqual(json?["command"] as? String, "colors") + XCTAssertEqual(json?["config"] as? String, "test.pkl") + XCTAssertEqual(json?["success"] as? Bool, true) + XCTAssertNil(json?["error"] as? String) + + let stats = json?["stats"] as? [String: Any] + XCTAssertEqual(stats?["colors"] as? Int, 42) + } + + // MARK: - Export Error Rethrown After Report Write + + func testExportErrorRethrownAfterReportWrite() async throws { + let reportPath = tempDirectory.appendingPathComponent("error.json").path + let ui = TerminalUI(outputMode: .quiet) + + struct TestExportError: Error, LocalizedError { + var errorDescription: String? { + "Test export failed" + } + } + + do { + try await withExportReport( + command: "icons", + assetType: "icon", + reportPath: reportPath, + configInput: "exfig.pkl", + ui: ui, + buildStats: { .icons($0) }, + export: { throw TestExportError() } + ) + XCTFail("Expected error to be rethrown") + } catch { + XCTAssertTrue(error is TestExportError) + } + + // Report should still be written + XCTAssertTrue(FileManager.default.fileExists(atPath: reportPath)) + + let data = try Data(contentsOf: URL(fileURLWithPath: reportPath)) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + XCTAssertEqual(json?["success"] as? Bool, false) + XCTAssertEqual(json?["error"] as? String, "Test export failed") + } + + // MARK: - Storage Cleaned Up After Success + + func testStorageCleanedUpAfterSuccess() async throws { + let reportPath = tempDirectory.appendingPathComponent("cleanup.json").path + let ui = TerminalUI(outputMode: .quiet) + + try await withExportReport( + command: "colors", + assetType: "color", + reportPath: reportPath, + configInput: "exfig.pkl", + ui: ui, + buildStats: { .colors($0) }, + export: { 1 } + ) + + XCTAssertNil(WarningCollectorStorage.current) + XCTAssertNil(ManifestTrackerStorage.current) + } + + // MARK: - Storage Cleaned Up After Failure + + func testStorageCleanedUpAfterFailure() async throws { + let reportPath = tempDirectory.appendingPathComponent("cleanup_fail.json").path + let ui = TerminalUI(outputMode: .quiet) + + struct TestError: Error {} + + do { + try await withExportReport( + command: "images", + assetType: "image", + reportPath: reportPath, + configInput: "exfig.pkl", + ui: ui, + buildStats: { .images($0) }, + export: { throw TestError() } + ) + } catch { + // expected + } + + XCTAssertNil(WarningCollectorStorage.current) + XCTAssertNil(ManifestTrackerStorage.current) + } + + // MARK: - Default Config Fallback + + func testDefaultConfigFallback() async throws { + let reportPath = tempDirectory.appendingPathComponent("default_config.json").path + let ui = TerminalUI(outputMode: .quiet) + + try await withExportReport( + command: "typography", + assetType: "typography", + reportPath: reportPath, + configInput: nil, + ui: ui, + buildStats: { .typography($0) }, + export: { 3 } + ) + + let data = try Data(contentsOf: URL(fileURLWithPath: reportPath)) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + XCTAssertEqual(json?["config"] as? String, "exfig.pkl") + } +} diff --git a/openspec/changes/export-report/.openspec.yaml b/openspec/changes/archive/2026-02-24-export-report/.openspec.yaml similarity index 100% rename from openspec/changes/export-report/.openspec.yaml rename to openspec/changes/archive/2026-02-24-export-report/.openspec.yaml diff --git a/openspec/changes/export-report/design.md b/openspec/changes/archive/2026-02-24-export-report/design.md similarity index 100% rename from openspec/changes/export-report/design.md rename to openspec/changes/archive/2026-02-24-export-report/design.md diff --git a/openspec/changes/export-report/proposal.md b/openspec/changes/archive/2026-02-24-export-report/proposal.md similarity index 100% rename from openspec/changes/export-report/proposal.md rename to openspec/changes/archive/2026-02-24-export-report/proposal.md diff --git a/openspec/changes/export-report/specs/export-report/spec.md b/openspec/changes/archive/2026-02-24-export-report/specs/export-report/spec.md similarity index 100% rename from openspec/changes/export-report/specs/export-report/spec.md rename to openspec/changes/archive/2026-02-24-export-report/specs/export-report/spec.md diff --git a/openspec/changes/export-report/tasks.md b/openspec/changes/archive/2026-02-24-export-report/tasks.md similarity index 58% rename from openspec/changes/export-report/tasks.md rename to openspec/changes/archive/2026-02-24-export-report/tasks.md index 8a58ce46..eee31a6f 100644 --- a/openspec/changes/export-report/tasks.md +++ b/openspec/changes/archive/2026-02-24-export-report/tasks.md @@ -1,44 +1,44 @@ ## 1. ExportReport Struct & JSON Serialization -- [ ] 1.1 Create `ExportReport` struct in `Sources/ExFigCLI/Report/ExportReport.swift` with fields: version (Int, default 1), command, config, startTime, endTime, duration, success, error, stats (ReportStats), warnings -- [ ] 1.2 Create `ReportStats: Encodable` struct with count fields only (colors, icons, images, typography) — analogous to `BatchReport.Stats` in `Batch.swift:901`. Do NOT add Codable to `ExportStats` (it has non-Codable batch-only fields: `computedNodeHashes`, `granularCacheStats`, `fileVersions`) -- [ ] 1.3 Add `Encodable` conformance to `ExportReport` and serialization via `JSONCodec.encodePrettySorted()` -- [ ] 1.4 Write unit tests for `ExportReport` JSON serialization (success case, failure case, empty warnings, version field) +- [x] 1.1 Create `ExportReport` struct in `Sources/ExFigCLI/Report/ExportReport.swift` with fields: version (Int, default 1), command, config, startTime, endTime, duration, success, error, stats (ReportStats), warnings +- [x] 1.2 Create `ReportStats: Encodable` struct with count fields only (colors, icons, images, typography) — analogous to `BatchReport.Stats` in `Batch.swift:901`. Do NOT add Codable to `ExportStats` (it has non-Codable batch-only fields: `computedNodeHashes`, `granularCacheStats`, `fileVersions`) +- [x] 1.3 Add `Encodable` conformance to `ExportReport` and serialization via `JSONCodec.encodePrettySorted()` +- [x] 1.4 Write unit tests for `ExportReport` JSON serialization (success case, failure case, empty warnings, version field) ## 2. --report Flag on Export Commands -- [ ] 2.1 Add `@Option(name: .long, help: "Path to write JSON report") var report: String?` to `ExportColors.swift` -- [ ] 2.2 Add `@Option(name: .long, help: "Path to write JSON report") var report: String?` to `ExportIcons.swift` -- [ ] 2.3 Add `@Option(name: .long, help: "Path to write JSON report") var report: String?` to `ExportImages.swift` -- [ ] 2.4 Add `@Option(name: .long, help: "Path to write JSON report") var report: String?` to `ExportTypography.swift` -- [ ] 2.5 Extract shared `writeExportReport(report:path:)` helper (wraps write in do/catch with warning on failure — same pattern as `Batch.swift:710-716`) -- [ ] 2.6 Modify each export command's `run()` to capture results: currently `_ = try await performExport(...)` discards the result. Change to capture count from `performExport()` (or use `performExportWithResult()`) and wrap in do/catch to capture errors. Record `startTime = Date()` before export, `endTime = Date()` after, build `ExportReport`, call `writeExportReport` +- [x] 2.1 Add `@Option(name: .long, help: "Path to write JSON report") var report: String?` to `ExportColors.swift` +- [x] 2.2 Add `@Option(name: .long, help: "Path to write JSON report") var report: String?` to `ExportIcons.swift` +- [x] 2.3 Add `@Option(name: .long, help: "Path to write JSON report") var report: String?` to `ExportImages.swift` +- [x] 2.4 Add `@Option(name: .long, help: "Path to write JSON report") var report: String?` to `ExportTypography.swift` +- [x] 2.5 Extract shared `writeExportReport(report:path:)` helper (wraps write in do/catch with warning on failure — same pattern as `Batch.swift:710-716`) +- [x] 2.6 Modify each export command's `run()` to capture results: currently `_ = try await performExport(...)` discards the result. Change to capture count from `performExport()` (or use `performExportWithResult()`) and wrap in do/catch to capture errors. Record `startTime = Date()` before export, `endTime = Date()` after, build `ExportReport`, call `writeExportReport` ## 3. Warning Collection -- [ ] 3.1 Create `WarningCollector` actor in `Sources/ExFigCLI/Report/WarningCollector.swift` — follow `SharedThemeAttributesCollector` pattern (`Sources/ExFigCLI/Batch/SharedThemeAttributes.swift`). Store warnings as `[String]`. Note: TerminalUI does NOT currently store warnings — it only prints them -- [ ] 3.2 Extend `TerminalUI.warning()` methods to forward formatted message to `WarningCollector` when one is active (pass via `@TaskLocal` or inject into TerminalUI). Only active when `--report` is specified -- [ ] 3.3 Integrate warning collection into `ExportReport` construction in each export command -- [ ] 3.4 Write tests for `WarningCollector` (add warnings, retrieve, empty state) +- [x] 3.1 Create `WarningCollector` actor in `Sources/ExFigCLI/Report/WarningCollector.swift` — follow `SharedThemeAttributesCollector` pattern (`Sources/ExFigCLI/Batch/SharedThemeAttributes.swift`). Store warnings as `[String]`. Note: TerminalUI does NOT currently store warnings — it only prints them +- [x] 3.2 Extend `TerminalUI.warning()` methods to forward formatted message to `WarningCollector` when one is active (pass via `@TaskLocal` or inject into TerminalUI). Only active when `--report` is specified +- [x] 3.3 Integrate warning collection into `ExportReport` construction in each export command +- [x] 3.4 Write tests for `WarningCollector` (add warnings, retrieve, empty state) ## 4. Asset Manifest (Phase 2) -- [ ] 4.1 Create `AssetManifest` and `ManifestEntry` structs with fields: path, action, checksum, assetType -- [ ] 4.2 Create `FileAction` enum: created, modified, unchanged, deleted -- [ ] 4.3 Add optional file tracking to `FileWriter`: before writing, check if file exists and compute `FNV1aHasher.hashToHex()` of new content. Compare with existing file hash to determine action (created/modified/unchanged). Only active when `--report` is specified — zero overhead otherwise -- [ ] 4.4 Compute content checksum via `FNV1aHasher.hashToHex()` (already in `Sources/ExFigCLI/Cache/FNV1aHasher.swift`) — NOT SHA256 (no CryptoKit/swift-crypto dependency in project). Produces 16-char lowercase hex -- [ ] 4.5 Add `manifest` field to `ExportReport` (optional, present when tracking enabled) -- [ ] 4.6 Write unit tests for FileWriter tracking (created, modified, unchanged detection) -- [ ] 4.7 Write unit tests for AssetManifest JSON serialization +- [x] 4.1 Create `AssetManifest` and `ManifestEntry` structs with fields: path, action, checksum, assetType +- [x] 4.2 Create `FileAction` enum: created, modified, unchanged, deleted +- [x] 4.3 Add optional file tracking to `FileWriter`: before writing, check if file exists and compute `FNV1aHasher.hashToHex()` of new content. Compare with existing file hash to determine action (created/modified/unchanged). Only active when `--report` is specified — zero overhead otherwise +- [x] 4.4 Compute content checksum via `FNV1aHasher.hashToHex()` (already in `Sources/ExFigCLI/Cache/FNV1aHasher.swift`) — NOT SHA256 (no CryptoKit/swift-crypto dependency in project). Produces 16-char lowercase hex +- [x] 4.5 Add `manifest` field to `ExportReport` (optional, present when tracking enabled) +- [x] 4.6 Write unit tests for FileWriter tracking (created, modified, unchanged detection) +- [x] 4.7 Write unit tests for AssetManifest JSON serialization ## 5. Deleted File Detection -- [ ] 5.1 Implement `deleted` action detection by comparing current manifest against previous report file at the same `--report` path -- [ ] 5.2 Write tests for deleted file detection (file in previous report but not in current export) +- [x] 5.1 Implement `deleted` action detection by comparing current manifest against previous report file at the same `--report` path +- [x] 5.2 Write tests for deleted file detection (file in previous report but not in current export) ## 6. Integration Testing -- [ ] 6.1 Write integration test: `exfig colors --report` produces valid JSON with version, command, stats, timestamps -- [ ] 6.2 Write integration test: export failure still writes report with `success: false` -- [ ] 6.3 Write integration test: report write failure does not fail the export -- [ ] 6.4 Write integration test: zero-file export produces report with empty manifest +- [x] 6.1 Write integration test: `exfig colors --report` produces valid JSON with version, command, stats, timestamps +- [x] 6.2 Write integration test: export failure still writes report with `success: false` +- [x] 6.3 Write integration test: report write failure does not fail the export +- [x] 6.4 Write integration test: zero-file export produces report with empty manifest diff --git a/openspec/specs/export-report/spec.md b/openspec/specs/export-report/spec.md new file mode 100644 index 00000000..a8519b7e --- /dev/null +++ b/openspec/specs/export-report/spec.md @@ -0,0 +1,264 @@ +# Export Report Capability + +Structured JSON reporting for single export commands with timing, stats, warnings, and asset manifest. + +## ADDED Requirements + +### Requirement: Export commands SHALL accept --report flag + +ExportColors, ExportIcons, ExportImages, and ExportTypography SHALL accept a `--report ` option that writes a JSON report file after export completes. + +#### Scenario: Write report after successful colors export + +- **WHEN** running `exfig colors -i exfig.pkl --report results.json` +- **THEN** colors are exported normally +- **AND** a JSON report file is written to `results.json` + +#### Scenario: Write report after successful icons export + +- **WHEN** running `exfig icons -i exfig.pkl --report report.json` +- **THEN** icons are exported normally +- **AND** a JSON report file is written to `report.json` + +#### Scenario: Write report after successful images export + +- **WHEN** running `exfig images -i exfig.pkl --report report.json` +- **THEN** images are exported normally +- **AND** a JSON report file is written to `report.json` + +#### Scenario: Write report after successful typography export + +- **WHEN** running `exfig typography -i exfig.pkl --report report.json` +- **THEN** typography is exported normally +- **AND** a JSON report file is written to `report.json` + +#### Scenario: No report written when flag is omitted + +- **WHEN** running `exfig colors -i exfig.pkl` without `--report` +- **THEN** colors are exported normally +- **AND** no report file is written + +--- + +### Requirement: ExportReport SHALL contain structured JSON with timing and metadata + +The report SHALL contain: `version` (integer, starting at 1), `command` (string: `"colors"`, `"icons"`, `"images"`, or `"typography"`), `config` (string path to PKL config), `startTime` (ISO8601 string), `endTime` (ISO8601 string), `duration` (number, seconds), `success` (boolean), `error` (string or null on success), `stats` (object), and `warnings` (string array). + +#### Scenario: Successful export produces complete report + +- **GIVEN** a valid PKL config with iOS colors entries +- **WHEN** running `exfig colors -i exfig.pkl --report results.json` +- **AND** the export completes successfully +- **THEN** the report JSON SHALL contain `"version": 1` +- **AND** `"command"` SHALL be `"colors"` +- **AND** `"config"` SHALL be the path to the PKL config file +- **AND** `"startTime"` SHALL be an ISO8601 timestamp before `"endTime"` +- **AND** `"duration"` SHALL be a positive number in seconds +- **AND** `"success"` SHALL be `true` +- **AND** `"error"` SHALL be `null` + +#### Scenario: Report includes ISO8601 timestamps + +- **GIVEN** an export that starts at time T1 and ends at time T2 +- **WHEN** the report is written +- **THEN** `"startTime"` SHALL be T1 formatted as ISO8601 +- **AND** `"endTime"` SHALL be T2 formatted as ISO8601 +- **AND** `"duration"` SHALL equal the difference between T2 and T1 in seconds + +--- + +### Requirement: Stats object SHALL contain asset counts + +The `stats` object in the report SHALL include `colors`, `icons`, `images`, and `typography` integer counts. This uses a new `ReportStats: Encodable` struct with count fields only (analogous to `BatchReport.Stats`), since `ExportStats` contains non-Codable batch-only fields. + +#### Scenario: Colors export populates stats correctly + +- **GIVEN** a colors export that processes 42 colors +- **WHEN** the report is written +- **THEN** `stats.colors` SHALL be `42` +- **AND** `stats.icons` SHALL be `0` +- **AND** `stats.images` SHALL be `0` +- **AND** `stats.typography` SHALL be `0` + +#### Scenario: Icons export populates stats correctly + +- **GIVEN** an icons export that processes 15 icons +- **WHEN** the report is written +- **THEN** `stats.icons` SHALL be `15` +- **AND** `stats.colors` SHALL be `0` + +--- + +### Requirement: All warnings SHALL be collected in the report + +When `--report` is specified, all warnings emitted via TerminalUI during export SHALL be collected by a `WarningCollector` and included in the report `warnings` array as strings. TerminalUI does not currently store warnings — a new collection mechanism is required. + +#### Scenario: Export with warnings includes them in report + +- **GIVEN** an export that emits 3 warnings during processing +- **WHEN** the report is written +- **THEN** `warnings` SHALL be an array containing exactly 3 string entries +- **AND** each warning message SHALL match the text displayed in the terminal + +#### Scenario: Export with no warnings produces empty array + +- **GIVEN** an export that completes without any warnings +- **WHEN** the report is written +- **THEN** `warnings` SHALL be an empty array `[]` + +--- + +### Requirement: Report write failure MUST NOT cause export to fail + +If the report file cannot be written (invalid path, permission denied, disk full), the system SHALL log a warning and continue. The export command SHALL exit with success status if the export itself succeeded. + +#### Scenario: Report write fails due to invalid path + +- **GIVEN** a successful colors export +- **WHEN** `--report /nonexistent/dir/report.json` is specified +- **AND** the directory does not exist +- **THEN** the export command SHALL exit with success status +- **AND** a warning SHALL be logged indicating the report could not be written + +#### Scenario: Report write fails due to permissions + +- **GIVEN** a successful icons export +- **WHEN** `--report /read-only/report.json` is specified +- **AND** the path is not writable +- **THEN** the export command SHALL exit with success status +- **AND** a warning SHALL be logged indicating the report could not be written + +--- + +### Requirement: Report SHALL be written even when export fails + +When the export itself fails with an error, the report SHALL still be written with `success: false` and `error` containing the error description. + +#### Scenario: Failed export produces error report + +- **GIVEN** an export that fails due to an invalid Figma token +- **WHEN** `--report results.json` is specified +- **THEN** a report file SHALL be written to `results.json` +- **AND** `"success"` SHALL be `false` +- **AND** `"error"` SHALL contain the error message string +- **AND** `"stats"` SHALL reflect any partial progress (or all zeros) + +#### Scenario: Failed export with report write failure + +- **GIVEN** an export that fails +- **WHEN** `--report` path is also unwritable +- **THEN** the export command SHALL exit with failure status (from the export error) +- **AND** a warning SHALL be logged about the report write failure + +--- + +### Requirement: Asset manifest SHALL track generated files + +When manifest tracking is enabled, the report SHALL include a `manifest` object with a `files` array. Each file entry SHALL contain: `path` (string, relative to working directory), `action` (string enum), `checksum` (FNV-1a 16-char hex string or null), and `assetType` (string). + +#### Scenario: Manifest lists all generated color files + +- **GIVEN** a colors export that generates 3 Swift files +- **WHEN** the report is written with manifest tracking +- **THEN** `manifest.files` SHALL contain 3 entries +- **AND** each entry SHALL have `assetType` equal to `"color"` +- **AND** each entry SHALL have a relative `path` string +- **AND** each entry SHALL have a non-null `checksum` + +#### Scenario: Manifest lists all generated icon files + +- **GIVEN** an icons export that generates 10 SVG assets and 1 Swift extension +- **WHEN** the report is written with manifest tracking +- **THEN** `manifest.files` SHALL contain 11 entries +- **AND** each entry SHALL have `assetType` equal to `"icon"` + +#### Scenario: Manifest paths are relative to working directory + +- **GIVEN** a working directory of `/project` +- **AND** an export that writes to `/project/Resources/Colors.swift` +- **WHEN** the manifest is generated +- **THEN** the file entry `path` SHALL be `"Resources/Colors.swift"` + +--- + +### Requirement: File action detection SHALL classify write operations + +The system SHALL detect and report the following file actions: `created` (file did not exist before write), `modified` (file existed but content changed), `unchanged` (file existed with identical content), and `deleted` (file existed in previous report but is no longer generated). + +#### Scenario: New file is marked as created + +- **GIVEN** an export writes a file to a path that does not exist +- **WHEN** the manifest entry is recorded +- **THEN** the `action` SHALL be `"created"` + +#### Scenario: Changed file is marked as modified + +- **GIVEN** an export writes a file to a path that already exists +- **AND** the new content differs from the existing file content +- **WHEN** the manifest entry is recorded +- **THEN** the `action` SHALL be `"modified"` + +#### Scenario: Identical file is marked as unchanged + +- **GIVEN** an export writes a file to a path that already exists +- **AND** the new content is identical to the existing file content +- **WHEN** the manifest entry is recorded +- **THEN** the `action` SHALL be `"unchanged"` + +#### Scenario: Missing file is marked as deleted + +- **GIVEN** a previous report at the same path lists a file entry +- **AND** the current export does not generate that file +- **WHEN** the manifest is finalized +- **THEN** a `"deleted"` entry SHALL be added for that file +- **AND** the `checksum` SHALL be `null` + +--- + +### Requirement: Content checksum SHALL be computed for manifest files + +Each file in the manifest SHALL include a `checksum` field containing an FNV-1a 64-bit hex digest of the file content (using `FNV1aHasher.hashToHex()` already in the codebase). This enables downstream tools to detect changes without reading file contents. FNV-1a is non-cryptographic but sufficient for change detection — same algorithm used by the granular cache system. + +#### Scenario: Written file has FNV-1a checksum + +- **GIVEN** an export writes a file with known content +- **WHEN** the manifest entry is recorded +- **THEN** `checksum` SHALL be a 16-character lowercase hexadecimal string +- **AND** the value SHALL match the FNV-1a hash of the written file content + +#### Scenario: Deleted file has null checksum + +- **GIVEN** a file marked with action `"deleted"` +- **WHEN** the manifest entry is recorded +- **THEN** `checksum` SHALL be `null` + +#### Scenario: Unchanged file has checksum matching existing content + +- **GIVEN** an export that detects a file is unchanged +- **WHEN** the manifest entry is recorded +- **THEN** `checksum` SHALL equal the FNV-1a hash of the existing file content + +--- + +### Requirement: Report SHALL include version field for forward compatibility + +The `version` field SHALL be an integer starting at `1`. It SHALL be incremented when breaking changes are made to the report schema structure. + +#### Scenario: Initial report version + +- **GIVEN** any export with `--report` +- **WHEN** the report is written +- **THEN** `"version"` SHALL be `1` + +--- + +### Requirement: Manifest SHALL handle zero-file exports gracefully + +When an export completes successfully but produces no output files, the manifest SHALL be present with an empty `files` array. + +#### Scenario: Export produces no files + +- **GIVEN** a valid config with no matching assets in Figma +- **WHEN** the export completes successfully with `--report` +- **THEN** `manifest.files` SHALL be an empty array `[]` +- **AND** `stats` SHALL reflect zero counts for the relevant asset type