diff --git a/Sources/CLI/cache/DocumentationCache.swift b/Sources/CLI/cache/DocumentationCache.swift new file mode 100644 index 0000000..4d67e45 --- /dev/null +++ b/Sources/CLI/cache/DocumentationCache.swift @@ -0,0 +1,13 @@ +import Foundation + +#if DEBUG + protocol DocumentationCache { + var currentDiskUsage: Int { get } + + func removeAllCachedResponses() + } + + extension URLCache: DocumentationCache {} +#else + typealias DocumentationCache = URLCache +#endif diff --git a/Sources/CLI/cmd/cache/CacheCleanCommand.swift b/Sources/CLI/cmd/cache/CacheCleanCommand.swift new file mode 100644 index 0000000..619df65 --- /dev/null +++ b/Sources/CLI/cmd/cache/CacheCleanCommand.swift @@ -0,0 +1,50 @@ +import ArgumentParser +import Logging +@preconcurrency import SentrySwift + +struct CacheCleanCommand: ParsableCommand { + private static let logger = Logger( + label: "com.techprimate.apple-docs.cache-clean" + ) + + static let configuration = CommandConfiguration( + commandName: "clean", + abstract: "Clear cached Apple documentation." + ) + + mutating func run() throws { + if SentrySDK.isEnabled { + let context = SentryCommandContext.cacheClean + let transaction = SentrySDK.startTransaction( + name: context.transactionName, + operation: "console.command", + bindToScope: true + ) + for (key, value) in context.attributes { + transaction.setData(value: value, key: key) + } + SentrySDK.configureScope { scope in + scope.setContext(value: context.attributes, key: "cli") + } + let breadcrumb = Breadcrumb( + level: .info, + category: SentryConfiguration.breadcrumbCategory + ) + breadcrumb.type = "user" + breadcrumb.message = "CLI command invoked" + for (key, value) in context.attributes { + breadcrumb.setData(value: value, key: key) + } + SentrySDK.addBreadcrumb(breadcrumb) + Self.logger.info( + "CLI command started", + metadata: context.logMetadata + ) + } + + let result = CacheCleanCommandRunner( + cache: Dependencies.documentationCache + ).run() + print(result.output) + } +} diff --git a/Sources/CLI/cmd/cache/CacheCleanCommandRunner.swift b/Sources/CLI/cmd/cache/CacheCleanCommandRunner.swift new file mode 100644 index 0000000..5a383f2 --- /dev/null +++ b/Sources/CLI/cmd/cache/CacheCleanCommandRunner.swift @@ -0,0 +1,34 @@ +import Foundation + +struct CacheCleanCommandRunner { + struct Result { + let clearedByteCount: Int? + let output: String + } + + private let cache: DocumentationCache? + + init(cache: DocumentationCache?) { + self.cache = cache + } + + func run() -> Result { + guard let cache else { + return Result( + clearedByteCount: nil, + output: "Apple documentation cache is unavailable." + ) + } + + let clearedByteCount = cache.currentDiskUsage + cache.removeAllCachedResponses() + let formattedByteCount = ByteCountFormatter.string( + fromByteCount: Int64(clearedByteCount), + countStyle: .file + ) + return Result( + clearedByteCount: clearedByteCount, + output: "Cleared \(formattedByteCount) of cached Apple documentation." + ) + } +} diff --git a/Sources/CLI/cmd/cache/CacheCommand.swift b/Sources/CLI/cmd/cache/CacheCommand.swift new file mode 100644 index 0000000..31e77c0 --- /dev/null +++ b/Sources/CLI/cmd/cache/CacheCommand.swift @@ -0,0 +1,9 @@ +import ArgumentParser + +struct CacheCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "cache", + abstract: "Manage cached Apple documentation.", + subcommands: [CacheCleanCommand.self] + ) +} diff --git a/Sources/CLI/main/CLI.swift b/Sources/CLI/main/CLI.swift index 942be66..7271d4e 100644 --- a/Sources/CLI/main/CLI.swift +++ b/Sources/CLI/main/CLI.swift @@ -11,6 +11,7 @@ struct CLI: AsyncParsableCommand { subcommands: [ TypesCommand.self, TechnologiesCommand.self, + CacheCommand.self, AgentCommand.self, ] ) diff --git a/Sources/CLI/main/Dependencies.swift b/Sources/CLI/main/Dependencies.swift index af8ea4c..92f9b92 100644 --- a/Sources/CLI/main/Dependencies.swift +++ b/Sources/CLI/main/Dependencies.swift @@ -1,7 +1,38 @@ import Foundation enum Dependencies { - static let httpDataTransport = URLSession.shared + static let httpCache: URLCache? = { + guard + let cachesDirectory = FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + ).first + else { + return nil + } + let cacheDirectory = cachesDirectory.appendingPathComponent( + "com.techprimate.apple-docs", + isDirectory: true + ) + return URLCache( + memoryCapacity: 16_000_000, + diskCapacity: 1_000_000_000, + directory: cacheDirectory + ) + }() + + static let httpDataTransport: URLSession = { + let configuration = URLSessionConfiguration.default + if let httpCache { + configuration.urlCache = httpCache + } + return URLSession(configuration: configuration) + }() + + static var documentationCache: URLCache? { + httpDataTransport.configuration.urlCache + } + static let documentationClient = DefaultAppleDocumentationClient( dependencies: httpDataTransport ) diff --git a/Sources/CLI/telemetry/SentryCommandContext.swift b/Sources/CLI/telemetry/SentryCommandContext.swift index c9ac6d1..d129423 100644 --- a/Sources/CLI/telemetry/SentryCommandContext.swift +++ b/Sources/CLI/telemetry/SentryCommandContext.swift @@ -7,6 +7,12 @@ struct SentryCommandContext: Equatable, Sendable { let technology: String? let typeName: String? + static let cacheClean = SentryCommandContext( + command: "cache.clean", + outputJSON: nil, + technology: nil, + typeName: nil + ) static let agentSkillsGet = SentryCommandContext( command: "agent.skills.get", outputJSON: nil, diff --git a/Tests/CLITests/cmd/cache/CacheCleanCommandRunnerTests.swift b/Tests/CLITests/cmd/cache/CacheCleanCommandRunnerTests.swift new file mode 100644 index 0000000..2a2fe92 --- /dev/null +++ b/Tests/CLITests/cmd/cache/CacheCleanCommandRunnerTests.swift @@ -0,0 +1,50 @@ +import Testing + +@testable import CLI + +@Suite("Cache clean command runner") +struct CacheCleanCommandRunnerTests { + @Test("clears cached responses and reports their disk usage") + func clearsCache() { + // -- Arrange -- + let cache = TestDocumentationCache(currentDiskUsage: 5_000_000) + let runner = CacheCleanCommandRunner(cache: cache) + + // -- Act -- + let result = runner.run() + + // -- Assert -- + #expect(cache.isEmpty) + #expect(result.clearedByteCount == 5_000_000) + #expect(result.output == "Cleared 5 MB of cached Apple documentation.") + } + + @Test("reports when no cache is available") + func reportsUnavailableCache() { + // -- Arrange -- + let runner = CacheCleanCommandRunner(cache: nil) + + // -- Act -- + let result = runner.run() + + // -- Assert -- + #expect(result.clearedByteCount == nil) + #expect(result.output == "Apple documentation cache is unavailable.") + } +} + +private final class TestDocumentationCache: DocumentationCache { + private(set) var currentDiskUsage: Int + + var isEmpty: Bool { + currentDiskUsage == 0 + } + + init(currentDiskUsage: Int) { + self.currentDiskUsage = currentDiskUsage + } + + func removeAllCachedResponses() { + currentDiskUsage = 0 + } +} diff --git a/Tests/CLITests/cmd/cache/CacheCleanCommandTests.swift b/Tests/CLITests/cmd/cache/CacheCleanCommandTests.swift new file mode 100644 index 0000000..24e5aa0 --- /dev/null +++ b/Tests/CLITests/cmd/cache/CacheCleanCommandTests.swift @@ -0,0 +1,18 @@ +import Testing + +@testable import CLI + +@Suite("Cache clean command") +struct CacheCleanCommandTests { + @Test("registers the cache clean command hierarchy") + func parsesCacheCleanCommand() throws { + // -- Arrange -- + let arguments = ["cache", "clean"] + + // -- Act -- + let command = try CLI.parseAsRoot(arguments) + + // -- Assert -- + #expect(command is CacheCleanCommand) + } +} diff --git a/Tests/CLITests/main/DependenciesTests.swift b/Tests/CLITests/main/DependenciesTests.swift new file mode 100644 index 0000000..f89bda7 --- /dev/null +++ b/Tests/CLITests/main/DependenciesTests.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Dependencies") +struct DependenciesTests { + @Test("uses a dedicated large documentation cache") + func usesDedicatedDocumentationCache() throws { + // -- Arrange -- + let session = Dependencies.httpDataTransport + + // -- Act -- + let cache = try #require(session.configuration.urlCache) + let documentationCache = try #require(Dependencies.documentationCache) + + // -- Assert -- + #expect(session !== URLSession.shared) + #expect(cache === documentationCache) + #expect(cache.diskCapacity == 1_000_000_000) + } +} diff --git a/Tests/CLITests/telemetry/SentryCommandContextTests.swift b/Tests/CLITests/telemetry/SentryCommandContextTests.swift index 610faba..c00043f 100644 --- a/Tests/CLITests/telemetry/SentryCommandContextTests.swift +++ b/Tests/CLITests/telemetry/SentryCommandContextTests.swift @@ -99,6 +99,24 @@ struct SentryCommandContextTests { #expect(context.logMetadata.keys.sorted() == expectedKeys) } + @Test("uses only the command name for cache clean telemetry") + func includesCacheCleanCommand() { + // -- Arrange -- + let expectedKeys = ["cli.command"] + + // -- Act -- + let context = SentryCommandContext.cacheClean + + // -- Assert -- + #expect(context.command == "cache.clean") + #expect(context.typeName == nil) + #expect(context.technology == nil) + #expect(context.outputJSON == nil) + #expect(context.attributes.keys.sorted() == expectedKeys) + #expect(context.metricAttributes.keys.sorted() == expectedKeys) + #expect(context.logMetadata.keys.sorted() == expectedKeys) + } + @Test("opts only output mode into technologies list telemetry") func includesTechnologiesListOutputMode() { // -- Arrange --