import Commander
import Foundation
import PeekabooCore
/// Clean up snapshot cache and temporary files
@available(macOS 14.0, *)
@MainActor
struct CleanCommand: OutputFormattable, RuntimeOptionsConfigurable {
static let commandDescription = CommandDescription(
commandName: "clean",
abstract: "Clean up snapshot cache and temporary files",
discussion: """
EXAMPLES:
peekaboo clean --all-snapshots # Remove all snapshot data
peekaboo clean --older-than 24 # Remove snapshots older than 24 hours
peekaboo clean --snapshot 12345 # Remove specific snapshot
peekaboo clean --dry-run # Preview what would be deleted
SNAPSHOT CACHE:
Snapshots are stored in ~/.peekaboo/snapshots/<snapshot-id>/
Each snapshot contains:
- snapshot.json: UI element mapping and metadata
""",
showHelpOnEmptyInvocation: true
)
@Flag(help: "Remove all snapshot data")
var allSnapshots = false
@Option(help: "Remove snapshots older than specified hours (default: 24)")
var olderThan: Int?
@Option(help: "Remove specific snapshot by ID")
var snapshot: String?
@Flag(help: "Show what would be deleted without actually deleting")
var dryRun = false
@RuntimeStorage private var runtime: CommandRuntime?
var runtimeOptions = CommandRuntimeOptions()
private var resolvedRuntime: CommandRuntime {
guard let runtime else {
preconditionFailure("CommandRuntime must be configured before accessing runtime resources")
}
return runtime
}
private var services: any PeekabooServiceProviding { self.resolvedRuntime.services }
private var logger: Logger { self.resolvedRuntime.logger }
var outputLogger: Logger { self.logger }
private var configuration: CommandRuntime.Configuration {
if let runtime {
return runtime.configuration
}
// During bare parsing in unit tests no runtime is injected; fall back
// to the parsed runtime options so flags like --json-output are visible.
return self.runtimeOptions.makeConfiguration()
}
var jsonOutput: Bool { self.configuration.jsonOutput }
@MainActor
mutating func run(using runtime: CommandRuntime) async throws {
self.runtime = runtime
let startTime = Date()
do {
// Validate options
let optionCount = [allSnapshots, olderThan != nil, self.snapshot != nil].count { $0 }
guard optionCount == 1 else {
throw ValidationError("Specify exactly one of: --all-snapshots, --older-than, or --snapshot")
}
// Perform cleanup based on option using the FileService
let result: SnapshotCleanResult
if self.allSnapshots {
result = try await self.services.files.cleanAllSnapshots(dryRun: self.dryRun)
} else if let hours = olderThan {
result = try await self.services.files.cleanOldSnapshots(hours: hours, dryRun: self.dryRun)
} else if let snapshotId = snapshot {
result = try await self.services.files.cleanSpecificSnapshot(
snapshotId: snapshotId,
dryRun: self.dryRun
)
} else {
throw ValidationError("No cleanup option specified")
}
// Calculate execution time
let executionTime = Date().timeIntervalSince(startTime)
// Output results
if self.jsonOutput {
var outputData = result
outputData.executionTime = executionTime
outputSuccessCodable(data: outputData, logger: self.outputLogger)
} else {
self.printResults(result, executionTime: executionTime)
}
} catch let error as ValidationError {
if self.jsonOutput {
outputError(message: error.localizedDescription, code: .VALIDATION_ERROR, logger: self.outputLogger)
} else {
var stderrStream = FileHandleTextOutputStream(FileHandle.standardError)
print("Error: \(error.localizedDescription)", to: &stderrStream)
}
throw ExitCode.failure
} catch let error as FileServiceError {
handleFileServiceError(error, jsonOutput: self.jsonOutput, logger: self.outputLogger)
throw ExitCode(1)
} catch {
if self.jsonOutput {
outputError(message: error.localizedDescription, code: .INTERNAL_SWIFT_ERROR, logger: self.outputLogger)
} else {
var localStandardErrorStream = FileHandleTextOutputStream(FileHandle.standardError)
print("Error: \(error.localizedDescription)", to: &localStandardErrorStream)
}
throw ExitCode.failure
}
}
private func printResults(_ result: SnapshotCleanResult, executionTime: TimeInterval) {
if result.dryRun {
print("🔍 Dry run mode - no files will be deleted")
print("")
}
if result.snapshotsRemoved == 0 {
print("✅ No snapshots to clean")
} else {
let action = result.dryRun ? "Would remove" : "Removed"
print("🗑️ \(action) \(result.snapshotsRemoved) snapshot\(result.snapshotsRemoved == 1 ? "" : "s")")
print("💾 Space \(result.dryRun ? "to be freed" : "freed"): \(self.formatBytes(result.bytesFreed))")
if result.snapshotDetails.count <= 5 {
print("\nSnapshots:")
for detail in result.snapshotDetails {
print(" - \(detail.snapshotId) (\(self.formatBytes(detail.size)))")
}
}
}
print("\n⏱️ Completed in \(String(format: "%.2f", executionTime))s")
}
private func formatBytes(_ bytes: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: bytes)
}
}
// MARK: - Error Handling
private func handleFileServiceError(_ error: FileServiceError, jsonOutput: Bool, logger: Logger) {
let errorCode: ErrorCode = switch error {
case .snapshotNotFound:
.SNAPSHOT_NOT_FOUND
case .directoryNotFound:
.FILE_IO_ERROR
case .insufficientPermissions:
.PERMISSION_DENIED
case .fileSystemError:
.FILE_IO_ERROR
}
if jsonOutput {
outputError(message: error.localizedDescription, code: errorCode, logger: logger)
} else {
var localStandardErrorStream = FileHandleTextOutputStream(FileHandle.standardError)
print("❌ \(error.localizedDescription)", to: &localStandardErrorStream)
}
}
extension CleanCommand: AsyncRuntimeCommand {}
@MainActor
extension CleanCommand: CommanderBindableCommand {
mutating func applyCommanderValues(_ values: CommanderBindableValues) throws {
self.allSnapshots = values.flag("allSnapshots")
self.dryRun = values.flag("dryRun")
self.olderThan = try values.decodeOption("olderThan", as: Int.self)
self.snapshot = values.singleOption("snapshot")
}
}