diff --git a/CHANGELOG.md b/CHANGELOG.md index 381fdc9..1ff8626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Add `attic status --json`: emits library, backup-progress, retry-queue, + and S3 manifest stats as a single stable JSON object on stdout, for + dashboards and monitoring scripts. (#7) - Add optional `storageClass` config setting. When set (e.g. `"GLACIER_IR"`), original asset uploads send `x-amz-storage-class` on single PUTs and multipart initiations. Manifest and metadata objects keep the provider diff --git a/Package.swift b/Package.swift index b183022..dc39f0f 100644 --- a/Package.swift +++ b/Package.swift @@ -32,7 +32,7 @@ let package = Package( ), .testTarget( name: "AtticCoreTests", - dependencies: ["AtticCore"], + dependencies: ["AtticCore", "AtticCLI"], path: "Tests/AtticCoreTests" ), ] diff --git a/README.md b/README.md index 61483c5..33eb923 100644 --- a/README.md +++ b/README.md @@ -130,8 +130,14 @@ summary (count, max attempts, oldest first-failed timestamp). ```bash attic status +attic status --json # machine-readable: single JSON object on stdout ``` +`--json` emits the same data as a stable JSON object (library counts, backup +progress, pending lanes, retry queue, manifest entries, last-backup +timestamp) for dashboards and monitoring scripts. Optional sections are +omitted — not null — when unavailable. + ### backup Export pending assets and upload originals + metadata JSON to S3. diff --git a/Sources/AtticCLI/Dependencies.swift b/Sources/AtticCLI/Dependencies.swift index 8266d0f..dfb0526 100644 --- a/Sources/AtticCLI/Dependencies.swift +++ b/Sources/AtticCLI/Dependencies.swift @@ -199,82 +199,28 @@ enum Dependencies { } /// Guard that runs at the top of every command needing a v2 manifest. - /// Detects v1 and pending thumbnail cleanup; prompts the user (TTY), - /// runs migration with progress, or fails fast on non-TTY with a clear - /// next-step message. Fast-paths to a no-op when both phases are - /// already applied. - static func ensureManifestMigrated() async throws { - let isTTY = isatty(STDOUT_FILENO) != 0 && isatty(STDIN_FILENO) != 0 - let (_, _, manifestStore) = try makeBackupDeps() - let manifest = try await loadManifest(store: manifestStore) - - let needsV1 = manifest.isV1 - let needsCleanup = !manifest.thumbnailsCleanupApplied - guard needsV1 || needsCleanup else { return } - - // v1 path keeps its existing pre-runner prompt. The runner detects - // and runs cleanup in the same invocation, so no second prompt is - // needed up-front for a v1+cleanup-pending bucket — the runner's - // confirm closure handles it. - if needsV1 { - FileHandle.standardError.write(Data(MigrationPrompt.message(count: manifest.entries.count).utf8)) - let decision = MigrationPrompt.decide( - isTTY: isTTY, - answer: { isTTY ? readLine() : nil }, - ) - switch decision { - case .nonInteractive: - FileHandle.standardError.write(Data(MigrationPrompt.nonInteractiveHint.utf8)) - throw CLIError.migrationRequired - case .abort: - throw CLIError.migrationRequired - case .proceed: - break - } - } - - let runner = try await makeMigrationRunner(progress: { msg in print(" \(msg)") }) - let confirmCleanup: MigrationRunner.CleanupConfirmHandler = { count, bytes in - guard isTTY else { return .nonInteractive } - FileHandle.standardError.write(Data( - MigrationPrompt.cleanupMessage(count: count, totalBytes: bytes).utf8, - )) - switch MigrationPrompt.decideCleanup(isTTY: isTTY, answer: { readLine() }) { - case .proceed: return .proceed - case .abort: return .abort - case .nonInteractive: return .nonInteractive - } - } - - let report: MigrationReport - do { - report = try await runner.run(confirmCleanup: confirmCleanup) - } catch let MigrationRunnerError.thumbnailCleanupNonInteractive(count, bytes) { - FileHandle.standardError.write(Data( - ("\nattic detected \(count) orphaned thumbnails (\(bytes) bytes) under " + - "`thumbnails/` and needs to delete them. Re-run from an interactive shell " + - "to confirm.\n").utf8, - )) - throw CLIError.migrationRequired - } catch MigrationRunnerError.thumbnailCleanupDeclined { - throw CLIError.migrationRequired - } catch let MigrationRunnerError.thumbnailCleanupPartial(deleted, failed) { - FileHandle.standardError.write(Data( - ("\nThumbnail cleanup partially failed: \(deleted) deleted, " + - "\(failed.count) failed. Re-run to retry.\n").utf8, - )) - throw CLIError.migrationRequired + /// See ``MigrationGuard`` for the control flow. With `statusToStderr` + /// (JSON mode), progress and completion output is routed to stderr so + /// stdout stays reserved for the machine-readable document. + static func ensureManifestMigrated(statusToStderr: Bool = false) async throws { + let emitStatus: @Sendable (String) -> Void = if statusToStderr { + { FileHandle.standardError.write(Data(($0 + "\n").utf8)) } + } else { + { print($0) } } - print("") - if needsV1 { - print("Migration complete: \(report.cloudMigrated) cloud, \(report.localFallback) local fallback.") - } - if report.thumbnailsDeleted > 0 { - let mb = Double(report.thumbnailsBytes) / (1024 * 1024) - print(" Thumbnails cleanup: \(report.thumbnailsDeleted) deleted (\(String(format: "%.1f", mb)) MB).") - } - print("") + let migrationGuard = MigrationGuard( + loadManifest: { + let (_, _, manifestStore) = try makeBackupDeps() + return try await loadManifest(store: manifestStore) + }, + makeRunner: { progress in try await makeMigrationRunner(progress: progress) }, + isTTY: isatty(STDOUT_FILENO) != 0 && isatty(STDIN_FILENO) != 0, + readAnswer: { readLine() }, + emitPrompt: { FileHandle.standardError.write(Data($0.utf8)) }, + emitStatus: emitStatus, + ) + try await migrationGuard.ensureMigrated() } } diff --git a/Sources/AtticCLI/MigrationGuard.swift b/Sources/AtticCLI/MigrationGuard.swift new file mode 100644 index 0000000..6482215 --- /dev/null +++ b/Sources/AtticCLI/MigrationGuard.swift @@ -0,0 +1,100 @@ +import AtticCore +import Foundation + +/// Guard that runs at the top of every command needing a v2 manifest. +/// Detects v1 and pending thumbnail cleanup; prompts the user (TTY), runs +/// migration with progress, or fails fast on non-TTY with a clear +/// next-step message. Fast-paths to a no-op when both phases are already +/// applied. +/// +/// Collaborators are injected so the control flow and output routing are +/// testable without real S3, config, or PhotoKit — +/// `Dependencies.ensureManifestMigrated` wires the live set. Prompts and +/// error hints go to `emitPrompt` (stderr in production); progress lines +/// and completion summaries go to `emitStatus`, which JSON-mode commands +/// route to stderr so stdout stays reserved for machine-readable output. +struct MigrationGuard: Sendable { + /// Load the current manifest from S3. + var loadManifest: @Sendable () async throws -> Manifest + /// Build a fully-wired migration runner reporting through the handler. + var makeRunner: @Sendable (@escaping MigrationRunner.ProgressHandler) async throws -> MigrationRunner + var isTTY: Bool + var readAnswer: @Sendable () -> String? + /// Sink for prompts and error hints — stderr in production. + var emitPrompt: @Sendable (String) -> Void + /// Sink for progress lines and completion summaries — stdout for human + /// invocations, stderr when stdout carries JSON (`status --json`). + /// A trailing newline is appended by the sink. + var emitStatus: @Sendable (String) -> Void + + func ensureMigrated() async throws { + let manifest = try await loadManifest() + + let needsV1 = manifest.isV1 + let needsCleanup = !manifest.thumbnailsCleanupApplied + guard needsV1 || needsCleanup else { return } + + // v1 path keeps its existing pre-runner prompt. The runner detects + // and runs cleanup in the same invocation, so no second prompt is + // needed up-front for a v1+cleanup-pending bucket — the runner's + // confirm closure handles it. + if needsV1 { + emitPrompt(MigrationPrompt.message(count: manifest.entries.count)) + let decision = MigrationPrompt.decide( + isTTY: isTTY, + answer: { isTTY ? readAnswer() : nil }, + ) + switch decision { + case .nonInteractive: + emitPrompt(MigrationPrompt.nonInteractiveHint) + throw CLIError.migrationRequired + case .abort: + throw CLIError.migrationRequired + case .proceed: + break + } + } + + let emitStatus = emitStatus + let runner = try await makeRunner { msg in emitStatus(" \(msg)") } + let confirmCleanup: MigrationRunner.CleanupConfirmHandler = { [isTTY, emitPrompt, readAnswer] count, bytes in + guard isTTY else { return .nonInteractive } + emitPrompt(MigrationPrompt.cleanupMessage(count: count, totalBytes: bytes)) + switch MigrationPrompt.decideCleanup(isTTY: isTTY, answer: { readAnswer() }) { + case .proceed: return .proceed + case .abort: return .abort + case .nonInteractive: return .nonInteractive + } + } + + let report: MigrationReport + do { + report = try await runner.run(confirmCleanup: confirmCleanup) + } catch let MigrationRunnerError.thumbnailCleanupNonInteractive(count, bytes) { + emitPrompt( + "\nattic detected \(count) orphaned thumbnails (\(bytes) bytes) under " + + "`thumbnails/` and needs to delete them. Re-run from an interactive shell " + + "to confirm.\n", + ) + throw CLIError.migrationRequired + } catch MigrationRunnerError.thumbnailCleanupDeclined { + throw CLIError.migrationRequired + } catch let MigrationRunnerError.thumbnailCleanupPartial(deleted, failed) { + emitPrompt( + "\nThumbnail cleanup partially failed: \(deleted) deleted, " + + "\(failed.count) failed. Re-run to retry.\n", + ) + throw CLIError.migrationRequired + } + + emitStatus("") + if needsV1 { + emitStatus("Migration complete: \(report.cloudMigrated) cloud, \(report.localFallback) local fallback.") + } + if report.thumbnailsDeleted > 0 { + let mb = Double(report.thumbnailsBytes) / (1024 * 1024) + emitStatus(" Thumbnails cleanup: \(report.thumbnailsDeleted) deleted (\(String(format: "%.1f", mb)) MB).") + } + emitStatus("") + } +} diff --git a/Sources/AtticCLI/StatusCommand.swift b/Sources/AtticCLI/StatusCommand.swift index 2b1360e..2f935ef 100644 --- a/Sources/AtticCLI/StatusCommand.swift +++ b/Sources/AtticCLI/StatusCommand.swift @@ -9,34 +9,70 @@ struct StatusCommand: AsyncParsableCommand { abstract: "Show library stats, backup progress, and S3 manifest info.", ) + @Flag(name: .long, help: "Emit status as a single JSON object on stdout.") + var json: Bool = false + func run() async throws { - try await Dependencies.ensureManifestMigrated() + let data = try await Self.buildDashboard( + ensureMigrated: { try await Dependencies.ensureManifestMigrated(statusToStderr: json) }, + loadAssets: { try await Dependencies.loadAssetsAsync() }, + loadLocalAvailability: { Dependencies.loadLocalAvailability() }, + loadRetryQueue: { FileRetryQueueStore().load() }, + loadBackupContext: { + let (config, _, manifestStore) = try Dependencies.makeBackupDeps() + let manifest = try await Dependencies.loadManifest(store: manifestStore) + return (bucket: config.bucket, manifest: manifest) + }, + ) - let isTTY = isatty(STDOUT_FILENO) != 0 - let assets = try await Dependencies.loadAssetsAsync() + if json { + let out = try formatDashboardJSON(data) + FileHandle.standardOutput.write(out) + FileHandle.standardOutput.write(Data("\n".utf8)) + } else { + StatusRenderer(isTTY: isatty(STDOUT_FILENO) != 0).render(data) + } + } + /// Assemble ``DashboardData`` from injected loaders. An unconfigured + /// install (`CLIError.notInitialized` from the migration guard or the + /// backup context) degrades to library-only data instead of failing, + /// so `status` works before `attic init`. + static func buildDashboard( + ensureMigrated: () async throws -> Void, + loadAssets: () async throws -> [AssetInfo], + loadLocalAvailability: () -> (any LocalAvailabilityProviding)?, + loadRetryQueue: () -> RetryQueue?, + loadBackupContext: () async throws -> (bucket: String, manifest: Manifest), + ) async throws -> DashboardData { + do { + try await ensureMigrated() + } catch CLIError.notInitialized { + // No config — the backup/S3 section below degrades the same way. + } + + let assets = try await loadAssets() let library = StatusStats.computeLibraryStats(assets) let types = StatusStats.computeUTIBreakdown(assets) - let localAvailability = Dependencies.loadLocalAvailability() - let retry = StatusStats.computeRetryInfo(FileRetryQueueStore().load()) + let localAvailability = loadLocalAvailability() + let retry = StatusStats.computeRetryInfo(loadRetryQueue()) var backup: BackupStats? var s3: S3Info? do { - let (config, _, manifestStore) = try Dependencies.makeBackupDeps() - let manifest = try await Dependencies.loadManifest(store: manifestStore) + let (bucket, manifest) = try await loadBackupContext() backup = StatusStats.computeBackupStats( assets: assets, manifest: manifest, localAvailability: localAvailability, ) - s3 = StatusStats.computeS3Info(bucket: config.bucket, manifest: manifest) + s3 = StatusStats.computeS3Info(bucket: bucket, manifest: manifest) } catch CLIError.notInitialized { // No config — show library only with init hint } - let data = DashboardData( + return DashboardData( version: AtticCore.version, library: library, backup: backup, @@ -44,7 +80,5 @@ struct StatusCommand: AsyncParsableCommand { types: types, retry: retry, ) - - StatusRenderer(isTTY: isTTY).render(data) } } diff --git a/Sources/AtticCore/StatusStats.swift b/Sources/AtticCore/StatusStats.swift index 38c7c41..f540179 100644 --- a/Sources/AtticCore/StatusStats.swift +++ b/Sources/AtticCore/StatusStats.swift @@ -107,12 +107,16 @@ public struct RetryInfo: Sendable, Equatable { public struct S3Info: Sendable, Equatable { public let bucket: String public let manifestEntries: Int + /// Display form of the newest backup time (YYYY-MM-DD). public let lastBackup: String? + /// Full ISO 8601 timestamp of the newest backup, for machine consumers. + public let lastBackupAt: String? - public init(bucket: String, manifestEntries: Int, lastBackup: String?) { + public init(bucket: String, manifestEntries: Int, lastBackup: String?, lastBackupAt: String? = nil) { self.bucket = bucket self.manifestEntries = manifestEntries self.lastBackup = lastBackup + self.lastBackupAt = lastBackupAt } } @@ -212,6 +216,56 @@ public enum StatusStats { bucket: bucket, manifestEntries: manifest.entries.count, lastBackup: displayDate, + lastBackupAt: lastBackup, ) } } + +/// Format ``DashboardData`` as a stable JSON object for `status --json`. +/// Pure: no I/O — caller writes to stdout. Optional sections (`backup`, +/// `s3`, `retry`) are omitted when unavailable rather than emitted as null. +public func formatDashboardJSON(_ data: DashboardData) throws -> Data { + var json: [String: Any] = [ + "version": data.version, + "library": [ + "photos": data.library.photos, + "videos": data.library.videos, + "favorites": data.library.favorites, + "edited": data.library.edited, + "total": data.library.total, + ], + "types": data.types.map { ["uti": $0.uti, "percentage": $0.percentage] }, + ] + if let backup = data.backup { + var section: [String: Any] = [ + "backedUp": backup.backedUp, + "backedUpBytes": backup.backedUpBytes, + "pending": backup.pending, + "total": backup.total, + "percentage": backup.percentage, + ] + section["pendingLocal"] = backup.pendingLocal + section["pendingCloud"] = backup.pendingCloud + json["backup"] = section + } + if let s3 = data.s3 { + var section: [String: Any] = [ + "bucket": s3.bucket, + "manifestEntries": s3.manifestEntries, + ] + section["lastBackupAt"] = s3.lastBackupAt + json["s3"] = section + } + if let retry = data.retry { + var section: [String: Any] = [ + "count": retry.count, + "maxAttempts": retry.maxAttempts, + ] + section["oldestFirstFailedAt"] = retry.oldestFirstFailedAt + json["retry"] = section + } + return try JSONSerialization.data( + withJSONObject: json, + options: [.prettyPrinted, .sortedKeys], + ) +} diff --git a/Tests/AtticCoreTests/DashboardJSONTests.swift b/Tests/AtticCoreTests/DashboardJSONTests.swift new file mode 100644 index 0000000..b7102ca --- /dev/null +++ b/Tests/AtticCoreTests/DashboardJSONTests.swift @@ -0,0 +1,93 @@ +@testable import AtticCore +import Foundation +import Testing + +struct DashboardJSONTests { + @Test func fullDashboardRoundTrips() throws { + let data = DashboardData( + version: "1.2.3", + library: LibraryStats(photos: 100, videos: 20, favorites: 5, edited: 3), + backup: BackupStats( + backedUp: 90, + backedUpBytes: 4096, + pending: 30, + total: 120, + pendingLocal: 10, + ), + s3: S3Info( + bucket: "my-bucket", + manifestEntries: 90, + lastBackup: "2026-08-20", + lastBackupAt: "2026-08-20T21:17:45Z", + ), + types: [TypeBreakdown(uti: "public.heic", percentage: 80.5)], + retry: RetryInfo(count: 2, maxAttempts: 5, oldestFirstFailedAt: "2026-08-19T03:30:00Z"), + ) + + let parsed = try #require( + try JSONSerialization.jsonObject(with: formatDashboardJSON(data)) as? [String: Any], + ) + + #expect(parsed["version"] as? String == "1.2.3") + let library = try #require(parsed["library"] as? [String: Any]) + #expect(library["photos"] as? Int == 100) + #expect(library["total"] as? Int == 120) + + let backup = try #require(parsed["backup"] as? [String: Any]) + #expect(backup["backedUp"] as? Int == 90) + #expect(backup["pending"] as? Int == 30) + #expect(backup["pendingLocal"] as? Int == 10) + #expect(backup["pendingCloud"] as? Int == 20) + #expect(backup["percentage"] as? Double == 75.0) + + let s3 = try #require(parsed["s3"] as? [String: Any]) + #expect(s3["bucket"] as? String == "my-bucket") + #expect(s3["manifestEntries"] as? Int == 90) + #expect(s3["lastBackupAt"] as? String == "2026-08-20T21:17:45Z") + + let retry = try #require(parsed["retry"] as? [String: Any]) + #expect(retry["count"] as? Int == 2) + #expect(retry["oldestFirstFailedAt"] as? String == "2026-08-19T03:30:00Z") + + let types = try #require(parsed["types"] as? [[String: Any]]) + #expect(types.first?["uti"] as? String == "public.heic") + } + + @Test func unconfiguredDashboardOmitsOptionalSections() throws { + let data = DashboardData( + version: "1.2.3", + library: LibraryStats(photos: 1, videos: 0, favorites: 0, edited: 0), + backup: nil, + s3: nil, + types: [], + ) + + let parsed = try #require( + try JSONSerialization.jsonObject(with: formatDashboardJSON(data)) as? [String: Any], + ) + + #expect(parsed["backup"] == nil) + #expect(parsed["s3"] == nil) + #expect(parsed["retry"] == nil) + #expect(parsed["library"] != nil) + #expect((parsed["types"] as? [Any])?.isEmpty == true) + } + + @Test func unknownLocalAvailabilityOmitsLaneFields() throws { + let data = DashboardData( + version: "1.2.3", + library: LibraryStats(photos: 1, videos: 0, favorites: 0, edited: 0), + backup: BackupStats(backedUp: 1, backedUpBytes: 10, pending: 0, total: 1, pendingLocal: nil), + s3: nil, + types: [], + ) + + let backup = try #require( + try (JSONSerialization.jsonObject(with: formatDashboardJSON(data)) as? [String: Any])?["backup"] + as? [String: Any], + ) + + #expect(backup["pendingLocal"] == nil) + #expect(backup["pendingCloud"] == nil) + } +} diff --git a/Tests/AtticCoreTests/MigrationGuardTests.swift b/Tests/AtticCoreTests/MigrationGuardTests.swift new file mode 100644 index 0000000..1ef24c9 --- /dev/null +++ b/Tests/AtticCoreTests/MigrationGuardTests.swift @@ -0,0 +1,136 @@ +@testable import AtticCLI +@testable import AtticCore +import Foundation +import LadderKit +import Testing + +private typealias Support = MigrationRunnerTestSupport + +/// Collects emitted output under a lock so `@Sendable` sinks can append +/// from the guard's closures. +private final class OutputSink: @unchecked Sendable { + private let lock = NSLock() + private var buffer = "" + + func append(_ s: String) { + lock.lock() + buffer += s + lock.unlock() + } + + var text: String { + lock.lock() + defer { lock.unlock() } + return buffer + } +} + +/// Coverage of `MigrationGuard` — the injectable migration gate behind +/// `Dependencies.ensureManifestMigrated` — with mock S3, resolver, and +/// output sinks in place of the real terminal. +struct MigrationGuardTests { + private func seedV1Bucket() async throws -> MockS3Provider { + let s3 = MockS3Provider() + try await s3.putObject( + key: manifestS3Key, + body: Support.v1ManifestData(entries: [("A", "originals/2024/01/A.heic")]), + contentType: "application/json", + ) + try await s3.putObject( + key: "metadata/assets/A.json", + body: Support.metadataJSON(uuid: "A", s3Key: "originals/2024/01/A.heic"), + contentType: "application/json", + ) + return s3 + } + + private func makeGuard( + s3: MockS3Provider, + prompts: OutputSink, + status: OutputSink, + isTTY: Bool = true, + answer: String? = "y", + ) -> MigrationGuard { + MigrationGuard( + loadManifest: { try await S3ManifestStore(s3: s3).load() }, + makeRunner: { progress in + MigrationRunner( + s3: s3, + manifestStore: S3ManifestStore(s3: s3), + resolver: Support.MockResolver(["A/L0/001": .cloud("CLOUD-A")]), + assetIdentifierProvider: { [(bareUUID: "A", fullLocalIdentifier: "A/L0/001")] }, + retryStore: Support.InMemoryRetryStore(), + unavailableStore: Support.InMemoryUnavailableStore(), + progress: progress, + ) + }, + isTTY: isTTY, + readAnswer: { answer }, + emitPrompt: { prompts.append($0) }, + emitStatus: { status.append($0 + "\n") }, + ) + } + + @Test func migrationNeededStatusJSONKeepsStdoutParseable() async throws { + // Simulate `attic status --json` against a v1 bucket: the guard + // runs the migration with progress routed to the status sink + // (stderr in JSON mode), and stdout receives only the JSON + // document. This is the regression test for human text polluting + // stdout ahead of the JSON object. + let s3 = try await seedV1Bucket() + let prompts = OutputSink() + let status = OutputSink() + let stdout = OutputSink() + + let migrationGuard = makeGuard(s3: s3, prompts: prompts, status: status) + let data = try await StatusCommand.buildDashboard( + ensureMigrated: { try await migrationGuard.ensureMigrated() }, + loadAssets: { [makeTestAsset(uuid: "A")] }, + loadLocalAvailability: { nil }, + loadRetryQueue: { nil }, + loadBackupContext: { try await ("bucket", S3ManifestStore(s3: s3).load()) }, + ) + let document = try #require(String(data: formatDashboardJSON(data), encoding: .utf8)) + stdout.append(document + "\n") + + // Migration actually ran, and its human output stayed off stdout. + #expect(status.text.contains("Migration complete: 1 cloud, 0 local fallback.")) + let parsed = try #require( + try JSONSerialization.jsonObject(with: Data(stdout.text.utf8)) as? [String: Any], + ) + #expect((parsed["s3"] as? [String: Any])?["manifestEntries"] as? Int == 1) + + let manifest = try await S3ManifestStore(s3: s3).load() + #expect(manifest.version == 2) + } + + @Test func alreadyMigratedBucketEmitsNothing() async throws { + let s3 = MockS3Provider() + let manifest = Manifest(version: 2, entries: [:], thumbnailsCleanupApplied: true) + try await s3.putObject( + key: manifestS3Key, + body: manifest.encoded(), + contentType: "application/json", + ) + let prompts = OutputSink() + let status = OutputSink() + + try await makeGuard(s3: s3, prompts: prompts, status: status).ensureMigrated() + + #expect(prompts.text.isEmpty) + #expect(status.text.isEmpty) + } + + @Test func nonInteractiveV1BucketFailsWithHint() async throws { + let s3 = try await seedV1Bucket() + let prompts = OutputSink() + let status = OutputSink() + + await #expect(throws: CLIError.self) { + try await makeGuard(s3: s3, prompts: prompts, status: status, isTTY: false, answer: nil) + .ensureMigrated() + } + #expect(prompts.text.contains(MigrationPrompt.nonInteractiveHint)) + #expect(status.text.isEmpty) + } +} diff --git a/Tests/AtticCoreTests/StatusCommandFlowTests.swift b/Tests/AtticCoreTests/StatusCommandFlowTests.swift new file mode 100644 index 0000000..b4d6e11 --- /dev/null +++ b/Tests/AtticCoreTests/StatusCommandFlowTests.swift @@ -0,0 +1,47 @@ +@testable import AtticCLI +@testable import AtticCore +import Foundation +import LadderKit +import Testing + +/// CLI-level coverage of `StatusCommand.buildDashboard` — the orchestration +/// behind `attic status` — using injected loaders instead of real config, +/// S3, or PhotoKit. +struct StatusCommandFlowTests { + @Test func unconfiguredStatusEmitsLibraryOnlyJSON() async throws { + // Before `attic init`, both the migration guard and the backup + // context throw `notInitialized`. Status must still produce a + // library-only dashboard that serializes to valid JSON. + let data = try await StatusCommand.buildDashboard( + ensureMigrated: { throw CLIError.notInitialized }, + loadAssets: { [makeTestAsset(uuid: "A")] }, + loadLocalAvailability: { nil }, + loadRetryQueue: { nil }, + loadBackupContext: { throw CLIError.notInitialized }, + ) + + let parsed = try #require( + try JSONSerialization.jsonObject(with: formatDashboardJSON(data)) as? [String: Any], + ) + + let library = try #require(parsed["library"] as? [String: Any]) + #expect(library["photos"] as? Int == 1) + #expect(parsed["backup"] == nil) + #expect(parsed["s3"] == nil) + #expect(parsed["retry"] == nil) + } + + @Test func statusStillFailsWhenMigrationIsDeclined() async throws { + // Only `notInitialized` is tolerated — a required-but-unrun + // migration must keep aborting the command. + await #expect(throws: CLIError.self) { + _ = try await StatusCommand.buildDashboard( + ensureMigrated: { throw CLIError.migrationRequired }, + loadAssets: { [] }, + loadLocalAvailability: { nil }, + loadRetryQueue: { nil }, + loadBackupContext: { throw CLIError.notInitialized }, + ) + } + } +}