From 84c700be11fab00c7f5ed0326a6cecd5c87ffc12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:11:15 +0000 Subject: [PATCH 1/2] Initial plan From f73bd2ec2991ed7028a9f53f12002ced2295eeb8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:19:56 +0000 Subject: [PATCH 2/2] Fix merge conflicts: integrate main changes into PR #7 Co-authored-by: elmoritz <2924483+elmoritz@users.noreply.github.com> --- Package.resolved | 11 +- Package.swift | 24 +- Sources/App/App.swift | 3 + .../Command/Coverage/CoverageCommand.swift | 54 +++- Sources/Command/Coverage/CoverageError.swift | 9 + Sources/Command/Coverage/CoverageTool.swift | 142 +++++++++ .../Command/Coverage/ThresholdValidator.swift | 95 ++++++ .../Trend/DerivedDataCommand+Helpers.swift | 58 ++++ Sources/Command/Trend/TrendCommand.swift | 134 +++++++++ Sources/Command/Trend/TrendError.swift | 34 +++ Sources/Command/Trend/TrendTool.swift | 203 +++++++++++++ .../Codables/Encoder/MarkDownEncoder.swift | 137 +++++++-- .../DBHandler/Models/CoverageModel.swift | 12 +- .../Helper/DBHandler/Models/ReportModel.swift | 18 +- .../Helper/DBHandler/Models/TargetModel.swift | 18 +- .../Repository/ReportModelRepository.swift | 115 +++++++ .../Collection+Glob.swift | 2 +- .../Exporters/Charts/SVGChartGenerator.swift | 280 ++++++++++++++++++ .../Exporters/Charts/TrendChartData.swift | 48 +++ .../Exporters/Markdown/GithubExport.swift | 28 +- Sources/Helper/Resources/ccConfig.yml | 24 ++ .../Helper/Threshold/ThresholdValidator.swift | 140 +++++++++ Sources/Shared/Config/Config+Thresholds.swift | 82 +++++ Sources/Shared/Config/Config+Tool.swift | 5 + Sources/Shared/Config/Config.swift | 12 +- .../Config/Settings/ThresholdSettings.swift | 92 ++++++ .../Coverage/ThresholdValidationResult.swift | 27 ++ 27 files changed, 1738 insertions(+), 69 deletions(-) create mode 100644 Sources/Command/Coverage/ThresholdValidator.swift create mode 100644 Sources/Command/Trend/DerivedDataCommand+Helpers.swift create mode 100644 Sources/Command/Trend/TrendCommand.swift create mode 100644 Sources/Command/Trend/TrendError.swift create mode 100644 Sources/Command/Trend/TrendTool.swift create mode 100644 Sources/Helper/Exporters/Charts/SVGChartGenerator.swift create mode 100644 Sources/Helper/Exporters/Charts/TrendChartData.swift create mode 100644 Sources/Helper/Threshold/ThresholdValidator.swift create mode 100644 Sources/Shared/Config/Config+Thresholds.swift create mode 100644 Sources/Shared/Config/Settings/ThresholdSettings.swift create mode 100644 Sources/Shared/Coverage/ThresholdValidationResult.swift diff --git a/Package.resolved b/Package.resolved index c304aff..876f1a1 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "60873c0b45a37b31cfc5357c208dcccd3352a6168706f20d5a2f8b8a480122d2", + "originHash" : "b2f77b9218130e79ac96aa55396e0ac6f17128f42dddc09925d250383375b7f5", "pins" : [ { "identity" : "async-http-client", @@ -172,6 +172,15 @@ "version" : "1.3.0" } }, + { + "identity" : "swift-glob", + "kind" : "remoteSourceControl", + "location" : "https://github.com/davbeck/swift-glob", + "state" : { + "revision" : "f039a675d39ba178f710980a64dd0a142e83dcde", + "version" : "1.0.0" + } + }, { "identity" : "swift-html", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 458f09a..43381f3 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,7 @@ extension PackageDescription.Target { static let prototype = TargetDefinition(name: "Prototype", path: "Sources/Command/Prototype") static let report = TargetDefinition(name: "Report", path: "Sources/Command/Report") static let shared = TargetDefinition(name: "Shared", path: "Sources/Shared") + static let trend = TargetDefinition(name: "Trend", path: "Sources/Command/Trend") } @MainActor @@ -28,10 +29,6 @@ enum ExternalDependencies { static let yams = Dependency(package: .package(url: "https://github.com/jpsim/Yams.git", from: "6.2.0"), target: .product(name: "Yams", package: "yams")) - // needs to be replaced by https://github.com/davbeck/swift-glob in the future - static let globPattern = Dependency(package: .package(url: "https://github.com/ChimeHQ/GlobPattern.git", from: "0.1.1"), - target: .product(name: "GlobPattern", package: "GlobPattern")) - static let asyncAlgorithms = Dependency(package: .package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.0.0"), target: .product(name: "AsyncAlgorithms", package: "swift-async-algorithms")) @@ -40,6 +37,9 @@ enum ExternalDependencies { static let sqlDriver = Dependency(package: .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.6.0"), target: .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver")) + + static let swiftGlob = Dependency(package: .package(url: "https://github.com/davbeck/swift-glob", from: "1.0.0"), + target: .product(name: "Glob", package: "swift-glob")) } typealias MyPackage = PackageDescription.Target @@ -57,7 +57,7 @@ let package = Package( ExternalDependencies.asyncAlgorithms.package, ExternalDependencies.fluent.package, ExternalDependencies.sqlDriver.package, - ExternalDependencies.globPattern.package, + ExternalDependencies.swiftGlob.package, ExternalDependencies.swiftHTMLParser.package, ExternalDependencies.yams.package, ], @@ -75,6 +75,7 @@ let package = Package( .build(), .config(), .report(), + .trend(), ], path: MyPackage.app.path ), @@ -142,6 +143,12 @@ let package = Package( .helper(), .shared(), ]), + MyPackage.trend.toTarget(dependencies: [ + ExternalDependencies.argumentParser.target, + .dependencyInjection(), + .helper(), + .shared(), + ]), // MARK: HELPER MyPackage.helper.toTarget( @@ -150,7 +157,7 @@ let package = Package( ExternalDependencies.asyncAlgorithms.target, ExternalDependencies.fluent.target, ExternalDependencies.sqlDriver.target, - ExternalDependencies.globPattern.target, + ExternalDependencies.swiftGlob.target, ExternalDependencies.swiftHTMLParser.target, ExternalDependencies.yams.target, .dependencyInjection(), @@ -271,6 +278,11 @@ extension PackageDescription.Target.Dependency { static func migrate() -> Target.Dependency { Target.Dependency.target(name: "Migrate") } + + /// SubCommand: `trend`: command to analyze coverage trends + static func trend() -> Target.Dependency { + Target.Dependency.target(name: "Trend") + } } struct TargetDefinition { diff --git a/Sources/App/App.swift b/Sources/App/App.swift index baf6e3d..8c95db6 100644 --- a/Sources/App/App.swift +++ b/Sources/App/App.swift @@ -7,6 +7,7 @@ import Foundation import Migrate import Prototype import Report +import Trend @main struct App: AsyncParsableCommand { @@ -26,6 +27,7 @@ struct App: AsyncParsableCommand { ReportCommand.self, CompareCommand.self, MigrateCommand.self, + TrendCommand.self, ] #else return [ @@ -34,6 +36,7 @@ struct App: AsyncParsableCommand { ConfigCommand.self, MigrateCommand.self, ReportCommand.self, + TrendCommand.self, ] #endif } diff --git a/Sources/Command/Coverage/CoverageCommand.swift b/Sources/Command/Coverage/CoverageCommand.swift index 95c7171..834402e 100644 --- a/Sources/Command/Coverage/CoverageCommand.swift +++ b/Sources/Command/Coverage/CoverageCommand.swift @@ -33,8 +33,14 @@ public final class CoverageCommand: DerivedDataCommand, QuietErrorHandling { @Option(name: [.customShort("f"), .customLong("format")], help: "Output format (json, csv, summary)") public var format: String? + @Option(name: .long, help: "Minimum coverage threshold percentage (overrides config)") + public var minCoverage: Double? + + @Option(name: .long, help: "Maximum coverage drop percentage (overrides config)") + public var maxDrop: Double? + enum CodingKeys: CodingKey { - case verbose, quiet, configFilePath, customGitRootpath, format + case verbose, quiet, configFilePath, customGitRootpath, format, minCoverage, maxDrop } public required init() {} @@ -77,6 +83,9 @@ public final class CoverageCommand: DerivedDataCommand, QuietErrorHandling { throw CoverageError.internalError } + // Load and merge threshold settings + let thresholdSettings = try loadThresholdSettings(from: config) + // Create and run coverage tool let coverageTool = CoverageTool( fileHandler: fileHandler, @@ -94,6 +103,7 @@ public final class CoverageCommand: DerivedDataCommand, QuietErrorHandling { locationCurrentReport: reportUrl, archiveLocation: archiveLocation, format: format, + thresholdSettings: thresholdSettings, verbose: verbose, quiet: quiet ) @@ -134,4 +144,46 @@ public final class CoverageCommand: DerivedDataCommand, QuietErrorHandling { throw error } } + + private func loadThresholdSettings(from config: Config) throws -> ThresholdSettings? { + // Load threshold settings from config + var configSettings: ThresholdSettings? + do { + configSettings = try config.settings(.threshold) as? ThresholdSettings + } catch { + // Threshold settings not configured in config file, which is fine + configSettings = nil + } + + // If we have CLI overrides, merge them with config settings + if minCoverage != nil || maxDrop != nil { + let configMinCoverage = configSettings?.minCoverage + let configMaxDrop = configSettings?.maxDrop + let configPerTargetThresholds = configSettings?.perTargetThresholds ?? [:] + + // CLI flags override config values + let finalMinCoverage = minCoverage ?? configMinCoverage + let finalMaxDrop = maxDrop ?? configMaxDrop + + // Create merged settings + var mergedDict: [String: String] = [:] + if let min = finalMinCoverage { + mergedDict["min_coverage"] = "\(min)" + } + if let max = finalMaxDrop { + mergedDict["max_drop"] = "\(max)" + } + if !configPerTargetThresholds.isEmpty { + let jsonData = try SingleEncoder.shared.encode(configPerTargetThresholds) + if let json = String(data: jsonData, encoding: .utf8) { + mergedDict["per_target_thresholds"] = json + } + } + + return try ThresholdSettings(values: mergedDict) + } + + // No CLI overrides, return config settings as-is + return configSettings + } } diff --git a/Sources/Command/Coverage/CoverageError.swift b/Sources/Command/Coverage/CoverageError.swift index 411a31b..a83a014 100644 --- a/Sources/Command/Coverage/CoverageError.swift +++ b/Sources/Command/Coverage/CoverageError.swift @@ -18,6 +18,9 @@ enum CoverageError: LocalizedError, CustomStringConvertible { case noResultsToWorkWith case noResultFilesToConvert case internalError + case thresholdFailedAbsolute(expected: Double, actual: Double) + case thresholdFailedRelative(maxDrop: Double, actualDrop: Double) + case thresholdFailedPerTarget(target: String, expected: Double, actual: Double) /// Retrieve the localized description for this error. var localizedDescription: String { @@ -42,6 +45,12 @@ enum CoverageError: LocalizedError, CustomStringConvertible { return "There are no xcresult files to work with" case .missingDatabasePath: return "No database path provided" + case let .thresholdFailedAbsolute(expected: expected, actual: actual): + return "Coverage threshold failed: Expected minimum \(String(format: "%.2f", expected))%, but actual coverage is \(String(format: "%.2f", actual))%" + case let .thresholdFailedRelative(maxDrop: maxDrop, actualDrop: actualDrop): + return "Coverage drop threshold exceeded: Maximum allowed drop is \(String(format: "%.2f", maxDrop))%, but coverage dropped by \(String(format: "%.2f", actualDrop))%" + case let .thresholdFailedPerTarget(target: target, expected: expected, actual: actual): + return "Target '\(target)' coverage threshold failed: Expected minimum \(String(format: "%.2f", expected))%, but actual coverage is \(String(format: "%.2f", actual))%" } } diff --git a/Sources/Command/Coverage/CoverageTool.swift b/Sources/Command/Coverage/CoverageTool.swift index 2c0f16b..9b16b87 100644 --- a/Sources/Command/Coverage/CoverageTool.swift +++ b/Sources/Command/Coverage/CoverageTool.swift @@ -27,6 +27,7 @@ class CoverageTool { private let locationCurrentReport: URL private let archiveLocation: URL private let format: String? + private let thresholdSettings: ThresholdSettings? private var logger: Loggerable { InjectedValues[\.logger] @@ -47,6 +48,7 @@ class CoverageTool { locationCurrentReport: URL, archiveLocation: URL, format: String? = nil, + thresholdSettings: ThresholdSettings? = nil, verbose: Bool = false, quiet: Bool = false) { @@ -61,6 +63,7 @@ class CoverageTool { self.archiveLocation = archiveLocation self.format = format self.repository = repository + self.thresholdSettings = thresholdSettings self.excludedPatterns = MatchPatternConfig(targets: excludedTargets, files: excludedFiles, functions: excludedFunctions) @@ -187,12 +190,151 @@ private extension CoverageTool { await githubExporter.createReport(with: current) try await repository.add(report: current) + + // Validate thresholds if configured + try await validateThresholds(current: current) + try await repository.shutDownDatabaseConnection() } catch { try? await repository.shutDownDatabaseConnection() throw error } } + + func validateThresholds(current: CoverageMetaReport) async throws { + guard let settings = thresholdSettings else { + // No threshold configuration, skip validation + return + } + + let validator = Helper.ThresholdValidator() + var hasFailures = false + + // Validate absolute threshold if configured + if let minCoverage = settings.minCoverage { + let result = validator.validateAbsolute(coverage: current.coverage, minCoverage: minCoverage) + if case .fail(let reason, let details) = result { + logger.error(reason) + printThresholdFailure( + type: "Absolute Coverage Threshold", + current: details.actual, + required: details.expected + ) + hasFailures = true + } + } + + // Validate relative threshold if configured + if let maxDrop = settings.maxDrop { + let previousReport = try? await repository.getLatestReport() + let previousCoverage = previousReport?.coverage + + let result = validator.validateRelative(current: current.coverage, previous: previousCoverage, maxDrop: maxDrop) + if case .fail(let reason, let details) = result { + logger.error(reason) + printRelativeThresholdFailure( + currentCoverage: current.coverage.coverage * 100.0, + previousCoverage: previousCoverage?.coverage ?? 0.0 * 100.0, + maxAllowedDrop: details.expected, + actualDrop: details.actual + ) + hasFailures = true + } + } + + // Validate per-target thresholds if configured + if !settings.perTargetThresholds.isEmpty { + let results = validator.validatePerTarget(coverage: current.coverage, thresholds: settings.perTargetThresholds) + + var failingTargets: [(name: String, current: Double, required: Double)] = [] + for result in results { + if case .fail(let reason, let details) = result { + logger.error(reason) + if let targetName = details.targetName { + failingTargets.append((name: targetName, current: details.actual, required: details.expected)) + } + } + } + + if !failingTargets.isEmpty { + printPerTargetThresholdFailures(targets: failingTargets) + hasFailures = true + } + } + + // Throw the first error encountered to maintain exit code behavior + if hasFailures { + if let minCoverage = settings.minCoverage { + let currentCoveragePercent = current.coverage.coverage * 100.0 + if currentCoveragePercent < minCoverage { + throw CoverageError.thresholdFailedAbsolute(expected: minCoverage, actual: currentCoveragePercent) + } + } + + if let maxDrop = settings.maxDrop { + let previousReport = try? await repository.getLatestReport() + if let previousCoverage = previousReport?.coverage { + let currentCoveragePercent = current.coverage.coverage * 100.0 + let previousCoveragePercent = previousCoverage.coverage * 100.0 + let actualDrop = previousCoveragePercent - currentCoveragePercent + if actualDrop > maxDrop { + throw CoverageError.thresholdFailedRelative(maxDrop: maxDrop, actualDrop: actualDrop) + } + } + } + + if !settings.perTargetThresholds.isEmpty { + for (targetName, config) in settings.perTargetThresholds { + if let target = current.coverage.targets.first(where: { $0.name == targetName }), + let minCoverage = config.minCoverage { + let targetCoveragePercent = target.coverage * 100.0 + if targetCoveragePercent < minCoverage { + throw CoverageError.thresholdFailedPerTarget(target: targetName, expected: minCoverage, actual: targetCoveragePercent) + } + } + } + } + } + } + + func printThresholdFailure(type: String, current: Double, required: Double) { + if quiet { return } + + print("\nāŒ \(type) Failed") + print(" Current: \(String(format: "%.2f", current))%") + print(" Required: \(String(format: "%.2f", required))%") + print(" Gap: \(String(format: "%.2f", required - current))%") + print("\nšŸ’” Action Required: Add tests to increase coverage by \(String(format: "%.2f", required - current)) percentage points\n") + } + + func printRelativeThresholdFailure(currentCoverage: Double, previousCoverage: Double, maxAllowedDrop: Double, actualDrop: Double) { + if quiet { return } + + print("\nāŒ Relative Coverage Threshold Failed") + print(" Previous: \(String(format: "%.2f", previousCoverage))%") + print(" Current: \(String(format: "%.2f", currentCoverage))%") + print(" Drop: \(String(format: "%.2f", actualDrop))%") + print(" Max Allowed: \(String(format: "%.2f", maxAllowedDrop))%") + print(" Exceeded By: \(String(format: "%.2f", actualDrop - maxAllowedDrop))%") + print("\nšŸ’” Action Required: Restore test coverage to previous levels or improve it\n") + } + + func printPerTargetThresholdFailures(targets: [(name: String, current: Double, required: Double)]) { + if quiet { return } + + print("\nāŒ Per-Target Coverage Thresholds Failed") + print(" The following targets did not meet their coverage requirements:\n") + + for target in targets { + print(" • \(target.name)") + print(" Current: \(String(format: "%.2f", target.current))%") + print(" Required: \(String(format: "%.2f", target.required))%") + print(" Gap: \(String(format: "%.2f", target.required - target.current))%") + print() + } + + print("šŸ’” Action Required: Add tests for the targets listed above\n") + } } // MARK: Helper diff --git a/Sources/Command/Coverage/ThresholdValidator.swift b/Sources/Command/Coverage/ThresholdValidator.swift new file mode 100644 index 0000000..eae1130 --- /dev/null +++ b/Sources/Command/Coverage/ThresholdValidator.swift @@ -0,0 +1,95 @@ +// +// ThresholdValidator.swift +// +// +// Created by Moritz Ellerbrock on 28.02.26. +// + +import DependencyInjection +import Foundation +import Shared + +/// Validates coverage reports against configured thresholds +class ThresholdValidator { + private let thresholds: Config.Thresholds + private let verbose: Bool + + private var logger: Loggerable { + InjectedValues[\.logger] + } + + init(thresholds: Config.Thresholds, verbose: Bool = false) { + self.thresholds = thresholds + self.verbose = verbose + } + + /// Validates all targets in a coverage report against their configured thresholds + /// - Parameter report: The coverage report to validate + /// - Returns: Array of validation results, one per target + func validate(report: CoverageReport) -> [ThresholdValidationResult] { + logger.log("Validating coverage thresholds for \(report.targets.count) targets") + + let results = report.targets.map { target in + validateTarget(target) + } + + let passed = results.filter(\.passed).count + let failed = results.count - passed + + if verbose { + logger.log("Threshold validation complete: \(passed) passed, \(failed) failed") + } + + return results + } + + /// Validates a single target against its threshold + /// - Parameter target: The target to validate + /// - Returns: Validation result for this target + private func validateTarget(_ target: Target) -> ThresholdValidationResult { + let threshold = thresholdForTarget(target.name) + let thresholdDecimal = threshold / 100.0 + let passed = target.coverage >= thresholdDecimal + + let result = ThresholdValidationResult( + targetName: target.name, + actualCoverage: target.coverage, + requiredThreshold: threshold, + passed: passed + ) + + if verbose { + let status = passed ? "āœ“" : "āœ—" + logger.log("\(status) \(target.name): \(target.printableCoverage)% (threshold: \(String(format: "%.2f", threshold))%)") + } + + return result + } + + /// Determines the threshold for a specific target + /// - Parameter targetName: Name of the target + /// - Returns: Coverage threshold percentage (e.g., 80.0 for 80%) + private func thresholdForTarget(_ targetName: String) -> Double { + // Check if there's a specific threshold for this target + if let targetThreshold = thresholds.targets?[targetName] { + return targetThreshold + } + + // Fall back to global threshold + return thresholds.global ?? 80.0 // Default to 80% if nothing is configured + } + + /// Checks if all targets passed their thresholds + /// - Parameter results: Array of validation results + /// - Returns: True if all targets passed + func allTargetsPassed(_ results: [ThresholdValidationResult]) -> Bool { + results.allSatisfy(\.passed) + } + + /// Gets the list of failed targets + /// - Parameter results: Array of validation results + /// - Returns: Array of failed results + func failedTargets(_ results: [ThresholdValidationResult]) -> [ThresholdValidationResult] { + results.filter { !$0.passed } + } +} diff --git a/Sources/Command/Trend/DerivedDataCommand+Helpers.swift b/Sources/Command/Trend/DerivedDataCommand+Helpers.swift new file mode 100644 index 0000000..8fdba84 --- /dev/null +++ b/Sources/Command/Trend/DerivedDataCommand+Helpers.swift @@ -0,0 +1,58 @@ +// +// DerivedDataCommand+Helpers.swift +// +// +// Created by Moritz Ellerbrock on 28.02.26. +// + +import DependencyInjection +import Foundation +import Helper +import Shared + +// MARK: - Default Implementations for Commands + +public extension DerivedDataCommand { + /// Setup logger with verbose flag + func setupLogger() { + InjectedValues[\.logger] = MyLogger.makeLogger(verbose: verbose) + } + + /// Load configuration from the specified path or default location + func loadConfig() async throws -> Config { + try await ConfigFactory.getConfig(at: URL(with: configFilePath)) + } + + /// Resolve the working directory based on custom path or git root + func resolveWorkingDirectory(using fileHandler: FileHandler) async -> URL { + if let customGitRootpath { + return URL(with: customGitRootpath) + } else if let gitRoot = await fileHandler.getGitRootDirectory().value { + return gitRoot + } else { + return fileHandler.getCurrentDirectoryUrl() + } + } + + /// Extract filter configuration from config + func extractFilters(from config: Config) -> FilterConfig { + FilterConfig( + excludedTargets: config.excluded?.targets ?? [], + excludedFiles: config.excluded?.files ?? [], + excludedFunctions: config.excluded?.functions ?? [], + includedTargets: config.included?.targets ?? [], + includedFiles: config.included?.files ?? [], + includedFunctions: config.included?.functions ?? [] + ) + } + + /// Create a new FileHandler instance + func makeFileHandler() -> FileHandler { + FileHandler() + } + + /// Create a new Tools instance + func makeTools() -> Tools { + Tools() + } +} diff --git a/Sources/Command/Trend/TrendCommand.swift b/Sources/Command/Trend/TrendCommand.swift new file mode 100644 index 0000000..6f523f5 --- /dev/null +++ b/Sources/Command/Trend/TrendCommand.swift @@ -0,0 +1,134 @@ +// +// TrendCommand.swift +// +// +// Created by Moritz Ellerbrock on 28.02.26. +// + +import ArgumentParser +import DependencyInjection +import Foundation +import Helper +import Shared + +public final class TrendCommand: DerivedDataCommand, QuietErrorHandling { + public static let configuration = CommandConfiguration( + commandName: "trend", + abstract: "Generate coverage trend charts showing coverage evolution over time" + ) + + public var logger: Loggerable { + InjectedValues[\.logger] + } + + @Flag(help: "activate extra logging") + public var verbose: Bool = false + + @Flag(help: "suppress failure") + private var quiet: Bool = false + + @Option(name: [.customShort("c"), .customLong("config")], help: "Path to the .xcrtool.yml") + public var configFilePath: String? + + @Option(name: [.customShort("g"), .customLong("gitroot")]) + public var customGitRootpath: String? + + @Option(name: [.customShort("d"), .customLong("days")], help: "Number of days of history to include") + private var days: Int? + + @Option(name: [.customShort("l"), .customLong("limit")], help: "Maximum number of reports to include") + private var limit: Int? + + @Option(name: [.customShort("t"), .customLong("targets")], help: "Target names to include in per-target trends") + private var targets: [String] = [] + + @Option(name: [.customLong("threshold")], help: "Coverage threshold to display as reference line") + private var threshold: Double? + + @Option(name: [.customShort("o"), .customLong("output")], help: "Output path for the SVG chart") + private var output: String = "coverage-trend.svg" + + enum CodingKeys: CodingKey { + case verbose, quiet, configFilePath, customGitRootpath, days, limit, targets, threshold, output + } + + public required init() {} + + public func run() async throws { + do { + try await Requirements.check() + + // Use protocol methods + setupLogger() + let config = try await loadConfig() + let fileHandler = makeFileHandler() + let workingDirectory = await resolveWorkingDirectory(using: fileHandler) + + // Setup database + guard let databasePath = config.locations?.databasePath else { + throw TrendError.chartGenerationFailed(reason: "Database path not configured") + } + + let repository = try await makeRepository( + databasePath: databasePath, + fileHandler: fileHandler + ) + + // Setup output path + let outputUrl: URL + if output.hasPrefix("/") { + outputUrl = URL(fileURLWithPath: output) + } else { + outputUrl = workingDirectory.appending(pathComponent: output) + } + + // Create and run trend tool + let trendTool = TrendTool( + fileHandler: fileHandler, + repository: repository, + days: days, + limit: limit, + targetFilters: targets, + threshold: threshold, + outputPath: outputUrl, + verbose: verbose, + quiet: quiet + ) + + try await trendTool.run() + } catch { + logger.error("Error: \(error.localizedDescription)") + try handle(error: error, quietly: quiet, helpMessage: Self.helpMessage()) + } + } + + // MARK: - Private Helpers + + private func makeRepository( + databasePath: String, + fileHandler: FileHandler + ) async throws -> ReportModelRepository { + do { + guard let root = await fileHandler.getGitRootDirectory().value else { + throw TrendError.chartGenerationFailed(reason: "Could not find git root directory") + } + + let cleanPath = databasePath.ensureFilePath(defaultFileName: "database.sqlite").relativeString + let databaseUrl = root.appending(pathComponent: cleanPath) + let urlWithoutFileName = databaseUrl.deletingLastPathComponent() + + try FileManager.default.createDirectory( + at: urlWithoutFileName, + withIntermediateDirectories: true + ) + + return try await Repository.makeRepository(with: databaseUrl) + } catch { + if let trendError = error as? TrendError { + throw trendError + } + + throw error + } + } +} diff --git a/Sources/Command/Trend/TrendError.swift b/Sources/Command/Trend/TrendError.swift new file mode 100644 index 0000000..568d9a8 --- /dev/null +++ b/Sources/Command/Trend/TrendError.swift @@ -0,0 +1,34 @@ +// +// TrendError.swift +// +// +// Created by Moritz Ellerbrock on 28.02.26. +// + +import Foundation + +enum TrendError: LocalizedError, CustomStringConvertible { + case noReportsInDatabase + case insufficientDataPoints(minimum: Int) + case invalidDateRange(start: Date, end: Date) + case targetNotFound(name: String) + case chartGenerationFailed(reason: String) + + /// Retrieve the localized description for this error. + var localizedDescription: String { + switch self { + case .noReportsInDatabase: + return "No coverage reports found in database. Run the coverage command first to generate reports." + case let .insufficientDataPoints(minimum: minimum): + return "Insufficient data points to generate trend chart. Need at least \(minimum) reports." + case let .invalidDateRange(start: start, end: end): + return "Invalid date range: start date (\(start)) must be before end date (\(end))" + case let .targetNotFound(name: name): + return "Target '\(name)' not found in coverage reports" + case let .chartGenerationFailed(reason: reason): + return "Failed to generate chart: \(reason)" + } + } + + var description: String { localizedDescription } +} diff --git a/Sources/Command/Trend/TrendTool.swift b/Sources/Command/Trend/TrendTool.swift new file mode 100644 index 0000000..05e9e68 --- /dev/null +++ b/Sources/Command/Trend/TrendTool.swift @@ -0,0 +1,203 @@ +// +// TrendTool.swift +// +// +// Created by Moritz Ellerbrock on 28.02.26. +// + +import DependencyInjection +import Foundation +import Helper +import Shared + +class TrendTool { + private let verbose: Bool + private let quiet: Bool + private let fileHandler: FileHandler + private let repository: ReportModelRepository + + private let days: Int? + private let limit: Int? + private let targetFilters: [String] + private let threshold: Double? + private let outputPath: URL + + private var logger: Loggerable { + InjectedValues[\.logger] + } + + init(fileHandler: FileHandler, + repository: ReportModelRepository, + days: Int? = nil, + limit: Int? = nil, + targetFilters: [String] = [], + threshold: Double? = nil, + outputPath: URL, + verbose: Bool = false, + quiet: Bool = false) + { + self.verbose = verbose + self.quiet = quiet + self.fileHandler = fileHandler + self.repository = repository + self.days = days + self.limit = limit + self.targetFilters = targetFilters + self.threshold = threshold + self.outputPath = outputPath + } +} + +extension TrendTool: Runnable { + func run() async throws { + do { + logger.log("setup completed") + + // Fetch reports from database + let reports = try await fetchReports() + logger.log("found \(reports.count) coverage reports") + + // Validate minimum data points + guard reports.count >= 2 else { + throw TrendError.insufficientDataPoints(minimum: 2) + } + + // Transform to chart data + let chartData = try buildChartData(from: reports) + logger.log("built chart data with \(chartData.overallTrend.count) data points") + + // Generate SVG + let svgGenerator = SVGChartGenerator(chartData: chartData) + let svg = svgGenerator.generateSVG() + logger.log("generated SVG chart") + + // Write to file + try fileHandler.writeContent(svg, at: outputPath, overwrite: true) + logger.log("wrote chart to \(outputPath.fullPath)") + + if !quiet { + print("āœ… Coverage trend chart generated successfully") + print("šŸ“Š Output: \(outputPath.fullPath)") + print("šŸ“ˆ Data points: \(reports.count)") + if let targetTrends = chartData.targetTrends { + print("šŸŽÆ Targets: \(targetTrends.map { $0.name }.joined(separator: ", "))") + } + } + + // Shutdown database connection on success + try await repository.shutDownDatabaseConnection() + } catch { + // Shutdown database connection on error + try? await repository.shutDownDatabaseConnection() + + logger.error("Error: \(error: error)") + if !quiet { + print("āŒ Error generating trend chart: \(error.localizedDescription)") + } + throw error + } + } +} + +private extension TrendTool { + func fetchReports() async throws -> [ReportModel] { + let reports: [ReportModel] + + if let days = days { + // Fetch reports from last N days + let startDate = Calendar.current.date(byAdding: .day, value: -days, to: Date()) ?? Date() + reports = try await repository.fetchReports(since: startDate) + } else if let limit = limit { + // Fetch last N reports + reports = try await repository.fetchReports(limit: limit) + } else { + // Default: last 30 days + let startDate = Calendar.current.date(byAdding: .day, value: -30, to: Date()) ?? Date() + reports = try await repository.fetchReports(since: startDate) + } + + guard !reports.isEmpty else { + throw TrendError.noReportsInDatabase + } + + return reports + } + + func buildChartData(from reports: [ReportModel]) throws -> TrendChartData { + // Build overall trend data points + var overallDataPoints: [TrendChartData.DataPoint] = [] + + for report in reports.reversed() { // Reverse to get chronological order + // Parse timestamp + guard let date = parseISO8601Date(report.timestamp) else { + continue + } + + // Calculate overall coverage + if let coverage = report.coverage, + !coverage.targets.isEmpty { + let totalExecutableLines = coverage.targets.reduce(0) { $0 + $1.executableLines } + let totalCoveredLines = coverage.targets.reduce(0) { $0 + $1.coveredLines } + + guard totalExecutableLines > 0 else { continue } + + let coveragePercentage = Double(totalCoveredLines) / Double(totalExecutableLines) + overallDataPoints.append(TrendChartData.DataPoint(date: date, coverage: coveragePercentage)) + } + } + + // Build per-target trends if filters specified + var targetTrends: [TrendChartData.TargetTrend]? = nil + + if !targetFilters.isEmpty { + var trendsDict: [String: [TrendChartData.DataPoint]] = [:] + + for report in reports.reversed() { // Reverse to get chronological order + guard let date = parseISO8601Date(report.timestamp), + let coverage = report.coverage else { + continue + } + + for target in coverage.targets { + // Check if target matches any filter + let matchesFilter = targetFilters.contains { filter in + target.name.contains(filter) + } + + guard matchesFilter else { continue } + + // Calculate target coverage + guard target.executableLines > 0 else { continue } + let coveragePercentage = Double(target.coveredLines) / Double(target.executableLines) + + // Add to trend + if trendsDict[target.name] == nil { + trendsDict[target.name] = [] + } + trendsDict[target.name]?.append(TrendChartData.DataPoint(date: date, coverage: coveragePercentage)) + } + } + + // Verify all requested targets were found + if trendsDict.isEmpty && !targetFilters.isEmpty { + throw TrendError.targetNotFound(name: targetFilters.joined(separator: ", ")) + } + + // Convert to array of TargetTrend + targetTrends = trendsDict.map { name, dataPoints in + TrendChartData.TargetTrend(name: name, dataPoints: dataPoints) + }.sorted { $0.name < $1.name } + } + + return TrendChartData( + overallTrend: overallDataPoints, + targetTrends: targetTrends, + threshold: threshold.map { $0 / 100.0 } + ) + } + + func parseISO8601Date(_ dateString: String) -> Date? { + let formatter = ISO8601DateFormatter() + return formatter.date(from: dateString) + } +} diff --git a/Sources/Helper/Codables/Encoder/MarkDownEncoder.swift b/Sources/Helper/Codables/Encoder/MarkDownEncoder.swift index 2db60b6..dcd621e 100644 --- a/Sources/Helper/Codables/Encoder/MarkDownEncoder.swift +++ b/Sources/Helper/Codables/Encoder/MarkDownEncoder.swift @@ -9,11 +9,11 @@ import Foundation import Shared public enum MarkdownEncoderType { - case header(meta: CoverageMetaReport) - case detailed(report: CoverageReport) - case topRanked(amount: Int, report: CoverageReport) - case lastRanked(amount: Int, report: CoverageReport) - case uncovered(report: CoverageReport) + case header(meta: CoverageMetaReport, validationResults: [ThresholdValidationResult]? = nil) + case detailed(report: CoverageReport, validationResults: [ThresholdValidationResult]? = nil) + case topRanked(amount: Int, report: CoverageReport, validationResults: [ThresholdValidationResult]? = nil) + case lastRanked(amount: Int, report: CoverageReport, validationResults: [ThresholdValidationResult]? = nil) + case uncovered(report: CoverageReport, validationResults: [ThresholdValidationResult]? = nil) case compare(current: CoverageReport, previous: CoverageReport?) } @@ -31,9 +31,9 @@ extension MarkdownEncoderType: CoverageReportEncoding { return "Coverage Report" case .detailed: return "All Target Ranked" - case let .topRanked(amount, _): + case let .topRanked(amount, _, _): return "TOP \(amount)" - case let .lastRanked(amount, _): + case let .lastRanked(amount, _, _): return "Last \(amount)" case .uncovered: return "!UNCOVERED TARGETS!" @@ -42,15 +42,51 @@ extension MarkdownEncoderType: CoverageReportEncoding { } } + var validationResults: [ThresholdValidationResult]? { + switch self { + case let .header(_, validationResults): + return validationResults + case let .detailed(_, validationResults): + return validationResults + case let .topRanked(_, _, validationResults): + return validationResults + case let .lastRanked(_, _, validationResults): + return validationResults + case let .uncovered(_, validationResults): + return validationResults + case .compare: + return nil + } + } + + private func validationResult(for targetName: String) -> ThresholdValidationResult? { + validationResults?.first { $0.targetName == targetName } + } + + private func thresholdIndicator(for targetName: String) -> String { + guard let result = validationResult(for: targetName) else { + return "" + } + return result.passed ? "āœ“" : "āœ—" + } + + private func thresholdStatus(for targetName: String) -> String { + guard let result = validationResult(for: targetName) else { + return "-" + } + let indicator = result.passed ? "āœ“" : "āœ—" + return "\(indicator) \(String(format: "%.1f", result.requiredThreshold))%" + } + public func encode() -> String { switch self { - case let .detailed(report): + case let .detailed(report, _): let relevantReports = report.targets .sorted(by: { $0.coverage > $1.coverage }) return encodeCoverageDetailed(relevantReports) - case let .topRanked(amount, report): + case let .topRanked(amount, report, _): let relevantReports = report.targets .filter { $0.coveredLines > 1 } .sorted(by: { $0.coverage > $1.coverage }) @@ -59,7 +95,7 @@ extension MarkdownEncoderType: CoverageReportEncoding { return encodeCoverageRanked(relevantReports) - case let .lastRanked(amount, report): + case let .lastRanked(amount, report, _): let relevantReports = report.targets .filter { $0.coveredLines > 1 } .sorted(by: { $0.coverage < $1.coverage }) @@ -69,7 +105,7 @@ extension MarkdownEncoderType: CoverageReportEncoding { return encodeCoverageRanked(relevantReports) - case let .uncovered(report): + case let .uncovered(report, _): let relevantReports = report.targets .filter { $0.coveredLines < 1 } .sorted(by: { $0.executableLines > $1.executableLines }) @@ -79,7 +115,7 @@ extension MarkdownEncoderType: CoverageReportEncoding { case let .compare(current, previous): return encodeCoverageCompared(current.targets, previous: previous?.targets) - case let .header(meta): + case let .header(meta, _): return encodeCoverageHeader(meta) } } @@ -87,8 +123,17 @@ extension MarkdownEncoderType: CoverageReportEncoding { extension MarkdownEncoderType { private func encodeCoverageRanked(_ targets: [Target]) -> String { - var result = "# \(title.uppercased())\n| Rank | Target | Coverage |\n" - result += "| :--- | :--- | :---: |\n" + let hasThresholds = validationResults != nil + var result = "# \(title.uppercased())\n" + + if hasThresholds { + result += "| Rank | Target | Coverage | Threshold | Status |\n" + result += "| :--- | :--- | :---: | :---: | :---: |\n" + } else { + result += "| Rank | Target | Coverage |\n" + result += "| :--- | :--- | :---: |\n" + } + for (index, target) in targets.enumerated() { result += encodeCoverageRankedSingleLine(index + 1, target: target) } @@ -96,14 +141,28 @@ extension MarkdownEncoderType { } private func encodeCoverageRankedSingleLine(_ rank: Int, target: Target) -> String { - "| \(rank). | \(target.name) | \(target.printableCoverage)% |\n" + guard validationResults != nil else { + return "| \(rank). | \(target.name) | \(target.printableCoverage)% |\n" + } + + let status = thresholdStatus(for: target.name) + return "| \(rank). | \(target.name) | \(target.printableCoverage)% | \(status) | \(thresholdIndicator(for: target.name)) |\n" } } extension MarkdownEncoderType { private func encodeCoverageDetailed(_ targets: [Target]) -> String { - var result = "# \(title.uppercased())\n| Rank | Target | Executable Lines | Covered Lines | Coverage |\n" - result += "| :--- | :--- | :---: | :---: | :---: |\n" + let hasThresholds = validationResults != nil + var result = "# \(title.uppercased())\n" + + if hasThresholds { + result += "| Rank | Target | Executable Lines | Covered Lines | Coverage | Threshold | Status |\n" + result += "| :--- | :--- | :---: | :---: | :---: | :---: | :---: |\n" + } else { + result += "| Rank | Target | Executable Lines | Covered Lines | Coverage |\n" + result += "| :--- | :--- | :---: | :---: | :---: |\n" + } + for (index, target) in targets.enumerated() { result += encodeCoverageDetailedSingleLine(index + 1, target: target) } @@ -111,7 +170,12 @@ extension MarkdownEncoderType { } private func encodeCoverageDetailedSingleLine(_ rank: Int, target: Target) -> String { - "| \(rank). | \(target.name) | \(target.executableLines) | \(target.coveredLines) | \(target.printableCoverage)% |\n" + guard validationResults != nil else { + return "| \(rank). | \(target.name) | \(target.executableLines) | \(target.coveredLines) | \(target.printableCoverage)% |\n" + } + + let status = thresholdStatus(for: target.name) + return "| \(rank). | \(target.name) | \(target.executableLines) | \(target.coveredLines) | \(target.printableCoverage)% | \(status) | \(thresholdIndicator(for: target.name)) |\n" } } @@ -154,6 +218,23 @@ extension MarkdownEncoderType { let date = DateFormat.fullWeekdayFullMonthNameDayYear.string(from: coverage.fileInfo.date) var result = "# \(title.uppercased()) (\(date))\n" result += "# \(String(format: "%.1f", percentage))% Overall Coverage\n" + + // Add threshold summary if validation results are available + if let validationResults = validationResults { + let passed = validationResults.filter(\.passed).count + let failed = validationResults.count - passed + let allPassed = failed == 0 + + result += "\n## Threshold Validation\n" + if allPassed { + result += "āœ“ All \(validationResults.count) target(s) passed their coverage thresholds\n" + } else { + result += "āš ļø \(failed) of \(validationResults.count) target(s) failed their coverage thresholds\n" + result += "- Passed: \(passed)\n" + result += "- Failed: \(failed)\n" + } + } + return result } @@ -200,8 +281,17 @@ extension MarkdownEncoderType { extension MarkdownEncoderType { private func encodeUncoverageDetailed(_ targets: [Target]) -> String { - var result = "# \(title.uppercased())\n| Rank | Target | Covered Lines | Executable Lines |\n" - result += "| :--- | :--- | :---: | :---: |\n" + let hasThresholds = validationResults != nil + var result = "# \(title.uppercased())\n" + + if hasThresholds { + result += "| Rank | Target | Covered Lines | Executable Lines | Threshold | Status |\n" + result += "| :--- | :--- | :---: | :---: | :---: | :---: |\n" + } else { + result += "| Rank | Target | Covered Lines | Executable Lines |\n" + result += "| :--- | :--- | :---: | :---: |\n" + } + for (index, target) in targets.enumerated() { result += encodeUncoverageDetailedSingleLine(index + 1, target: target) } @@ -209,6 +299,11 @@ extension MarkdownEncoderType { } private func encodeUncoverageDetailedSingleLine(_ rank: Int, target: Target) -> String { - "| \(rank). | \(target.name) | \(target.coveredLines) | \(target.executableLines) |\n" + guard validationResults != nil else { + return "| \(rank). | \(target.name) | \(target.coveredLines) | \(target.executableLines) |\n" + } + + let status = thresholdStatus(for: target.name) + return "| \(rank). | \(target.name) | \(target.coveredLines) | \(target.executableLines) | \(status) | \(thresholdIndicator(for: target.name)) |\n" } } diff --git a/Sources/Helper/DBHandler/Models/CoverageModel.swift b/Sources/Helper/DBHandler/Models/CoverageModel.swift index 2562f91..05ca1cb 100644 --- a/Sources/Helper/DBHandler/Models/CoverageModel.swift +++ b/Sources/Helper/DBHandler/Models/CoverageModel.swift @@ -8,21 +8,21 @@ import Foundation import FluentKit -final class CoverageModel: Model { +public final class CoverageModel: Model { typealias FieldKeyStore = ModelDefinition.Coverage.FieldKeys - static let schema = ModelDefinition.Coverage.schema + public static let schema = ModelDefinition.Coverage.schema @ID(key: .id) - var id: UUID? + public var id: UUID? @Parent(key: FieldKeyStore.report) - var report: ReportModel + public var report: ReportModel @Children(for: \.$coverage) - var targets: [TargetModel] + public var targets: [TargetModel] - init() {} + public init() {} init(id: UUID? = nil, report reportId: ReportModel.IDValue) { diff --git a/Sources/Helper/DBHandler/Models/ReportModel.swift b/Sources/Helper/DBHandler/Models/ReportModel.swift index d39f4fe..3ed592b 100644 --- a/Sources/Helper/DBHandler/Models/ReportModel.swift +++ b/Sources/Helper/DBHandler/Models/ReportModel.swift @@ -8,30 +8,30 @@ import Foundation import FluentKit -final class ReportModel: Model { +public final class ReportModel: Model { typealias FieldKeyStore = ModelDefinition.Report.FieldKeys - static let schema = ModelDefinition.Report.schema + public static let schema = ModelDefinition.Report.schema @ID(key: .id) - var id: UUID? + public var id: UUID? @Field(key: FieldKeyStore.date) - var timestamp: String + public var timestamp: String @Field(key: FieldKeyStore.type) - var type: String + public var type: String @Field(key: FieldKeyStore.url) - var url: String + public var url: String @Field(key: FieldKeyStore.application) - var application: String + public var application: String @OptionalChild(for: \.$report) - private var coverageChild: CoverageModel? + public var coverage: CoverageModel? - init() {} + public init() {} init(id: UUID? = nil, date: Date, diff --git a/Sources/Helper/DBHandler/Models/TargetModel.swift b/Sources/Helper/DBHandler/Models/TargetModel.swift index 6caa522..6be9bd9 100644 --- a/Sources/Helper/DBHandler/Models/TargetModel.swift +++ b/Sources/Helper/DBHandler/Models/TargetModel.swift @@ -8,27 +8,27 @@ import Foundation import FluentKit -final class TargetModel: Model { +public final class TargetModel: Model { typealias FieldKeyStore = ModelDefinition.Target.FieldKeys - static let schema = ModelDefinition.Target.schema + public static let schema = ModelDefinition.Target.schema @ID(key: .id) - var id: UUID? + public var id: UUID? @Field(key: FieldKeyStore.executableLines) - var executableLines: Int - + public var executableLines: Int + @Field(key: FieldKeyStore.coveredLines) - var coveredLines: Int + public var coveredLines: Int @Parent(key: FieldKeyStore.coverage) - var coverage: CoverageModel + public var coverage: CoverageModel @Field(key: FieldKeyStore.name) - var name: String + public var name: String - init() {} + public init() {} init(id: UUID? = nil, name: String, executableLines: Int, coveredLines: Int, coverageId: CoverageModel.IDValue) { self.id = id diff --git a/Sources/Helper/DBHandler/Repository/ReportModelRepository.swift b/Sources/Helper/DBHandler/Repository/ReportModelRepository.swift index dc90ee7..2b02cf8 100644 --- a/Sources/Helper/DBHandler/Repository/ReportModelRepository.swift +++ b/Sources/Helper/DBHandler/Repository/ReportModelRepository.swift @@ -15,7 +15,11 @@ enum ReportModelRepositoryError: Error { public protocol ReportModelRepository { func add(report: CoverageMetaReport) async throws + func getLatestReport() async throws -> CoverageMetaReport? func shutDownDatabaseConnection() async throws + func fetchReports(limit: Int) async throws -> [ReportModel] + func fetchReports(since date: Date) async throws -> [ReportModel] + func fetchReports(between startDate: Date, and endDate: Date) async throws -> [ReportModel] } struct ReportModelRepositoryImpl { @@ -92,6 +96,37 @@ private extension ReportModelRepositoryImpl { throw error } } + + func reconstructCoverageMetaReport(from reportModel: ReportModel, coverage coverageModel: CoverageModel) throws -> CoverageMetaReport { + // Reconstruct XCResultFile by creating a properly formatted URL + // The stored url field contains just the filename (e.g., "Run-AppName-2023.05.08_15-14-43-+0200.xcresult") + // We need to create a full path URL for the initializer to work + let fileURL = URL(fileURLWithPath: "/tmp/\(reportModel.url)") + let fileInfo = try XCResultFile(with: fileURL) + + // Reconstruct Target objects from TargetModel + // Database only stores target-level coverage data, not file-level details + let reconstructedTargets = coverageModel.targets.map { targetModel in + // Create a synthetic File to hold the coverage data since DB doesn't store file details + let syntheticFunction = Function( + name: "coverage", + executableLines: targetModel.executableLines, + coveredLines: targetModel.coveredLines, + lineNumber: 0, + executionCount: 0 + ) + let syntheticFile = File( + name: targetModel.name, + path: "", + functions: [syntheticFunction] + ) + return Target(name: targetModel.name, files: [syntheticFile]) + } + + let coverageReport = CoverageReport(targets: reconstructedTargets) + + return CoverageMetaReport(fileInfo: fileInfo, coverage: coverageReport) + } } extension ReportModelRepositoryImpl: ReportModelRepository { @@ -107,10 +142,90 @@ extension ReportModelRepositoryImpl: ReportModelRepository { } } + func getLatestReport() async throws -> CoverageMetaReport? { + do { + // Query for the most recent report by timestamp + guard let reportModel = try await reportModelQuery() + .sort(\.$timestamp, .descending) + .first() + else { + return nil + } + + guard let reportId = reportModel.id else { + return nil + } + + // Load the coverage model for this report + guard let coverageModel = try await coverageModelQuery() + .filter(\.$report.$id == reportId) + .with(\.$targets) + .first() + else { + return nil + } + + // Reconstruct CoverageMetaReport from database models + return try reconstructCoverageMetaReport(from: reportModel, coverage: coverageModel) + } catch { + logger.error(.init(stringLiteral: String(reflecting: error))) + throw error + } + } + func shutDownDatabaseConnection() async throws { try await connector.disconnect() } + func fetchReports(limit: Int) async throws -> [ReportModel] { + do { + return try await reportModelQuery() + .with(\.$coverage) { coverage in + coverage.with(\.$targets) + } + .sort(\.$timestamp, .descending) + .limit(limit) + .all() + } catch { + logger.error(.init(stringLiteral: String(reflecting: error))) + throw error + } + } + + func fetchReports(since date: Date) async throws -> [ReportModel] { + do { + let dateString = date.ISO8601Format(.iso8601) + return try await reportModelQuery() + .with(\.$coverage) { coverage in + coverage.with(\.$targets) + } + .filter(\.$timestamp >= dateString) + .sort(\.$timestamp, .descending) + .all() + } catch { + logger.error(.init(stringLiteral: String(reflecting: error))) + throw error + } + } + + func fetchReports(between startDate: Date, and endDate: Date) async throws -> [ReportModel] { + do { + let startDateString = startDate.ISO8601Format(.iso8601) + let endDateString = endDate.ISO8601Format(.iso8601) + return try await reportModelQuery() + .with(\.$coverage) { coverage in + coverage.with(\.$targets) + } + .filter(\.$timestamp >= startDateString) + .filter(\.$timestamp <= endDateString) + .sort(\.$timestamp, .descending) + .all() + } catch { + logger.error(.init(stringLiteral: String(reflecting: error))) + throw error + } + } + private func make(_ coverage: CoverageReport, parent: ReportModel.IDValue) async throws { do { let cleanedUpCoverageReport = coverage.removingCommonPrefix() diff --git a/Sources/Helper/Dependency+Extensions/Collection+Glob.swift b/Sources/Helper/Dependency+Extensions/Collection+Glob.swift index 7f67815..4553f61 100644 --- a/Sources/Helper/Dependency+Extensions/Collection+Glob.swift +++ b/Sources/Helper/Dependency+Extensions/Collection+Glob.swift @@ -6,7 +6,7 @@ // import Foundation -import GlobPattern +import Glob import Shared public extension Collection where Element == Glob.Pattern { diff --git a/Sources/Helper/Exporters/Charts/SVGChartGenerator.swift b/Sources/Helper/Exporters/Charts/SVGChartGenerator.swift new file mode 100644 index 0000000..834f221 --- /dev/null +++ b/Sources/Helper/Exporters/Charts/SVGChartGenerator.swift @@ -0,0 +1,280 @@ +// +// SVGChartGenerator.swift +// +// +// Created by Moritz Ellerbrock on 28.02.26. +// + +import Foundation +import Shared + +/// Generates self-contained SVG trend charts from coverage data +public class SVGChartGenerator { + private let chartData: TrendChartData + private let width: Double + private let height: Double + private let padding: Padding + + /// Chart dimensions configuration + public struct Padding { + let top: Double + let right: Double + let bottom: Double + let left: Double + + public init(top: Double = 60, right: Double = 120, bottom: Double = 60, left: Double = 60) { + self.top = top + self.right = right + self.bottom = bottom + self.left = left + } + } + + public init( + chartData: TrendChartData, + width: Double = 1200, + height: Double = 600, + padding: Padding = Padding() + ) { + self.chartData = chartData + self.width = width + self.height = height + self.padding = padding + } + + /// Generate the complete SVG chart as a string + public func generateSVG() -> String { + var svg = svgHeader() + svg += svgStyles() + svg += svgTitle() + svg += svgGrid() + svg += svgAxes() + svg += svgDataLines() + svg += svgThresholdLine() + svg += svgLegend() + svg += svgFooter() + return svg + } + + // MARK: - SVG Components + + private func svgHeader() -> String { + return """ + + + + """ + } + + private func svgStyles() -> String { + return """ + + + + + """ + } + + private func svgTitle() -> String { + return """ + Coverage Trend History + + """ + } + + private func svgGrid() -> String { + let chartWidth = width - padding.left - padding.right + let chartHeight = height - padding.top - padding.bottom + var grid = "" + + // Horizontal grid lines (for coverage percentages) + for i in 0...10 { + let y = padding.top + (chartHeight * Double(i) / 10.0) + grid += " \n" + } + + // Vertical grid lines (for dates) + let dataPoints = chartData.overallTrend.count + if dataPoints > 1 { + let step = max(1, dataPoints / 10) + for i in stride(from: 0, to: dataPoints, by: step) { + let x = padding.left + (chartWidth * Double(i) / Double(dataPoints - 1)) + grid += " \n" + } + } + + grid += "\n" + return grid + } + + private func svgAxes() -> String { + let chartWidth = width - padding.left - padding.right + let chartHeight = height - padding.top - padding.bottom + var axes = "" + + // X-axis + axes += " \n" + + // Y-axis + axes += " \n" + + // Y-axis labels (coverage percentages) + for i in 0...10 { + let y = padding.top + (chartHeight * Double(i) / 10.0) + let percentage = 100 - (i * 10) + axes += " \(percentage)%\n" + } + + // X-axis labels (dates) + let dataPoints = chartData.overallTrend.count + if dataPoints > 0 { + let step = max(1, dataPoints / 6) // Show max 6 date labels + for i in stride(from: 0, to: dataPoints, by: step) { + let x = padding.left + (chartWidth * Double(i) / Double(dataPoints - 1)) + let dateLabel = formatDate(chartData.overallTrend[i].date) + axes += " \(dateLabel)\n" + } + + // Always show the last date + if dataPoints > 1 { + let lastIndex = dataPoints - 1 + let x = padding.left + chartWidth + let dateLabel = formatDate(chartData.overallTrend[lastIndex].date) + axes += " \(dateLabel)\n" + } + } + + axes += "\n" + return axes + } + + private func svgDataLines() -> String { + var lines = "" + + // Overall coverage line + if !chartData.overallTrend.isEmpty { + lines += " \n" + + // Data points + for point in chartData.overallTrend { + let coords = calculateCoordinates(point) + lines += " \n" + } + } + + // Per-target lines + if let targetTrends = chartData.targetTrends { + let colors = ["#4CAF50", "#FF9800", "#9C27B0", "#00BCD4", "#FFEB3B", "#795548"] + for (index, targetTrend) in targetTrends.enumerated() { + let color = colors[index % colors.count] + if !targetTrend.dataPoints.isEmpty { + lines += " \n" + } + } + } + + lines += "\n" + return lines + } + + private func svgThresholdLine() -> String { + guard let threshold = chartData.threshold else { return "" } + + let chartHeight = height - padding.top - padding.bottom + let y = padding.top + chartHeight * (1.0 - threshold) + + var line = "" + line += " \n" + line += " Threshold: \(Int(threshold * 100))%\n" + line += "\n" + return line + } + + private func svgLegend() -> String { + var legend = "" + let legendX = width - padding.right + 10 + var legendY = padding.top + 20 + let lineHeight: Double = 25 + + // Overall coverage + legend += " \n" + legend += " Overall\n" + legendY += lineHeight + + // Target trends + if let targetTrends = chartData.targetTrends { + let colors = ["#4CAF50", "#FF9800", "#9C27B0", "#00BCD4", "#FFEB3B", "#795548"] + for (index, targetTrend) in targetTrends.enumerated() { + let color = colors[index % colors.count] + legend += " \n" + legend += " \(targetTrend.name)\n" + legendY += lineHeight + } + } + + // Threshold + if chartData.threshold != nil { + legend += " \n" + legend += " Threshold\n" + } + + legend += "\n" + return legend + } + + private func svgFooter() -> String { + return "\n" + } + + // MARK: - Helper Methods + + private func createLinePath(_ dataPoints: [TrendChartData.DataPoint]) -> String { + guard !dataPoints.isEmpty else { return "" } + + var path = "" + for (index, point) in dataPoints.enumerated() { + let coords = calculateCoordinates(point) + if index == 0 { + path += "M \(coords.x) \(coords.y)" + } else { + path += " L \(coords.x) \(coords.y)" + } + } + return path + } + + private func calculateCoordinates(_ dataPoint: TrendChartData.DataPoint) -> (x: Double, y: Double) { + let chartWidth = width - padding.left - padding.right + let chartHeight = height - padding.top - padding.bottom + + // Find the index of this data point in the overall trend + guard let index = chartData.overallTrend.firstIndex(where: { $0.date == dataPoint.date }) else { + return (x: padding.left, y: padding.top) + } + + let dataPoints = chartData.overallTrend.count + let xRatio = dataPoints > 1 ? Double(index) / Double(dataPoints - 1) : 0 + let x = padding.left + (chartWidth * xRatio) + + // Y coordinate (inverted because SVG Y grows downward) + let yRatio = 1.0 - dataPoint.coverage + let y = padding.top + (chartHeight * yRatio) + + return (x: x, y: y) + } + + private func formatDate(_ date: Date) -> String { + return DateFormat.dayAbbreviatedMonthYear.string(from: date) + } +} diff --git a/Sources/Helper/Exporters/Charts/TrendChartData.swift b/Sources/Helper/Exporters/Charts/TrendChartData.swift new file mode 100644 index 0000000..a44a1c2 --- /dev/null +++ b/Sources/Helper/Exporters/Charts/TrendChartData.swift @@ -0,0 +1,48 @@ +// +// TrendChartData.swift +// +// +// Created by Moritz Ellerbrock on 28.02.26. +// + +import Foundation + +/// Model representing trend chart data for SVG generation +public struct TrendChartData { + /// Individual data point in the chart + public struct DataPoint { + public let date: Date + public let coverage: Double + + public init(date: Date, coverage: Double) { + self.date = date + self.coverage = coverage + } + } + + /// Per-target trend line data + public struct TargetTrend { + public let name: String + public let dataPoints: [DataPoint] + + public init(name: String, dataPoints: [DataPoint]) { + self.name = name + self.dataPoints = dataPoints + } + } + + /// Overall coverage trend data points + public let overallTrend: [DataPoint] + + /// Optional per-target trend lines + public let targetTrends: [TargetTrend]? + + /// Optional threshold line value (0.0 to 1.0) + public let threshold: Double? + + public init(overallTrend: [DataPoint], targetTrends: [TargetTrend]? = nil, threshold: Double? = nil) { + self.overallTrend = overallTrend + self.targetTrends = targetTrends + self.threshold = threshold + } +} diff --git a/Sources/Helper/Exporters/Markdown/GithubExport.swift b/Sources/Helper/Exporters/Markdown/GithubExport.swift index e7cffc5..b3d7c9b 100644 --- a/Sources/Helper/Exporters/Markdown/GithubExport.swift +++ b/Sources/Helper/Exporters/Markdown/GithubExport.swift @@ -69,11 +69,7 @@ public class GithubExport { } } - public func createMarkDownReport(with current: CoverageMetaReport) async { - await createReport(with: current) - } - - public func createReport(with current: CoverageMetaReport) async { + public func createMarkDownReport(with current: CoverageMetaReport, validationResults: [ThresholdValidationResult]? = nil) async { do { try await setupAndDelete() @@ -81,7 +77,7 @@ public class GithubExport { let previous: CoverageMetaReport? = try? archiver.lastReport(before: current.fileInfo.date) // create new file - let fileContent = createFileContent(with: current, previous: previous) + let fileContent = createFileContent(with: current, previous: previous, validationResults: validationResults) try saveReport(content: fileContent, at: reportUrl) } catch { logger.error(error.localizedDescription) @@ -89,13 +85,17 @@ public class GithubExport { } } + public func createReport(with current: CoverageMetaReport) async { + await createMarkDownReport(with: current) + } + private func setupAndDelete() async throws { try await archiver.setup() // delete old file deleteReport() } - private func createFileContent(with current: CoverageMetaReport, previous: CoverageMetaReport?) -> String { + private func createFileContent(with current: CoverageMetaReport, previous: CoverageMetaReport?, validationResults: [ThresholdValidationResult]? = nil) -> String { let selectedFormat = format?.lowercased() ?? "markdown" switch selectedFormat { @@ -104,21 +104,21 @@ public class GithubExport { case "json", "json-summary": return createJSONContent(with: current, previous: previous) default: - return createMarkdownContent(with: current, previous: previous) + return createMarkdownContent(with: current, previous: previous, validationResults: validationResults) } } - private func createMarkdownContent(with current: CoverageMetaReport, previous: CoverageMetaReport?) -> String { + private func createMarkdownContent(with current: CoverageMetaReport, previous: CoverageMetaReport?, validationResults: [ThresholdValidationResult]? = nil) -> String { var fileContent = "" - fileContent += MarkdownEncoderType.header(meta: current).encode() + fileContent += MarkdownEncoderType.header(meta: current, validationResults: validationResults).encode() fileContent += "\n" - fileContent += MarkdownEncoderType.topRanked(amount: settings.top, report: current.coverage).encode() + fileContent += MarkdownEncoderType.topRanked(amount: settings.top, report: current.coverage, validationResults: validationResults).encode() fileContent += "\n" - fileContent += MarkdownEncoderType.lastRanked(amount: settings.last, report: current.coverage).encode() + fileContent += MarkdownEncoderType.lastRanked(amount: settings.last, report: current.coverage, validationResults: validationResults).encode() fileContent += "\n" - fileContent += MarkdownEncoderType.uncovered(report: current.coverage).encode() + fileContent += MarkdownEncoderType.uncovered(report: current.coverage, validationResults: validationResults).encode() fileContent += "\n" - fileContent += MarkdownEncoderType.detailed(report: current.coverage).encode() + fileContent += MarkdownEncoderType.detailed(report: current.coverage, validationResults: validationResults).encode() fileContent += "\n" fileContent += MarkdownEncoderType.compare(current: current.coverage, previous: previous?.coverage).encode() fileContent += "\n" diff --git a/Sources/Helper/Resources/ccConfig.yml b/Sources/Helper/Resources/ccConfig.yml index f45db10..286c659 100644 --- a/Sources/Helper/Resources/ccConfig.yml +++ b/Sources/Helper/Resources/ccConfig.yml @@ -5,6 +5,30 @@ locations: output_directory_html: HTMLReport/ output_directory_json: CodeCoverage/ +# Coverage Thresholds (optional) +# Uncomment to enforce code coverage standards in CI/CD pipelines +# tools: +# threshold: +# # Absolute threshold: require minimum overall coverage percentage +# min_coverage: 80.0 +# +# # Relative threshold: prevent coverage from dropping compared to previous report +# max_drop: 2.0 +# +# # Per-target thresholds: enforce different rules for specific targets +# # Format: JSON-encoded string mapping target names to threshold configurations +# # Each target can have its own minCoverage and/or maxDrop values +# per_target_thresholds: '{"MyApp": {"minCoverage": 85.0}, "CriticalFramework": {"minCoverage": 90.0, "maxDrop": 1.0}}' +# +# Exit Codes: +# - 0: All thresholds passed +# - 1: One or more thresholds failed +# +# CLI Overrides: +# You can override these values with command-line flags: +# --min-coverage 80.0 +# --max-drop 2.0 + excluded: files: - *ViewController.swift diff --git a/Sources/Helper/Threshold/ThresholdValidator.swift b/Sources/Helper/Threshold/ThresholdValidator.swift new file mode 100644 index 0000000..ba6d19e --- /dev/null +++ b/Sources/Helper/Threshold/ThresholdValidator.swift @@ -0,0 +1,140 @@ +// +// ThresholdValidator.swift +// +// +// Created by Moritz Ellerbrock on 28.02.26. +// + +import Foundation +import Shared + +public enum ThresholdResult: Sendable { + case pass + case fail(reason: String, details: ThresholdFailureDetails) + + public var isPassing: Bool { + if case .pass = self { + return true + } + return false + } +} + +public struct ThresholdFailureDetails: Sendable { + public let expected: Double + public let actual: Double + public let targetName: String? + + public init(expected: Double, actual: Double, targetName: String? = nil) { + self.expected = expected + self.actual = actual + self.targetName = targetName + } +} + +public class ThresholdValidator { + public init() {} + + /// Validates that coverage meets or exceeds the minimum absolute threshold + /// - Parameters: + /// - coverage: The coverage report to validate + /// - minCoverage: Minimum coverage percentage (0-100) + /// - Returns: ThresholdResult indicating pass or fail + public func validateAbsolute(coverage: CoverageReport, minCoverage: Double) -> ThresholdResult { + let currentCoveragePercent = coverage.coverage * 100.0 + + guard currentCoveragePercent >= minCoverage else { + let reason = String(format: "Coverage %.2f%% is below minimum threshold %.2f%%", + currentCoveragePercent, minCoverage) + let details = ThresholdFailureDetails(expected: minCoverage, + actual: currentCoveragePercent) + return .fail(reason: reason, details: details) + } + + return .pass + } + + /// Validates that coverage has not dropped more than the maximum allowed amount + /// - Parameters: + /// - current: The current coverage report + /// - previous: The previous coverage report (optional) + /// - maxDrop: Maximum allowed coverage drop in percentage points (0-100) + /// - Returns: ThresholdResult indicating pass or fail + public func validateRelative(current: CoverageReport, + previous: CoverageReport?, + maxDrop: Double) -> ThresholdResult { + guard let previous = previous else { + // No previous report to compare against, so we pass + return .pass + } + + let currentCoveragePercent = current.coverage * 100.0 + let previousCoveragePercent = previous.coverage * 100.0 + let actualDrop = previousCoveragePercent - currentCoveragePercent + + guard actualDrop <= maxDrop else { + let reason = String(format: "Coverage dropped %.2f%%, exceeding maximum allowed drop of %.2f%%", + actualDrop, maxDrop) + let details = ThresholdFailureDetails(expected: maxDrop, actual: actualDrop) + return .fail(reason: reason, details: details) + } + + return .pass + } + + /// Validates that each target meets its configured threshold + /// - Parameters: + /// - coverage: The coverage report to validate + /// - thresholds: Dictionary mapping target names to their threshold configurations + /// - Returns: Array of ThresholdResults, one per target with configured thresholds + public func validatePerTarget(coverage: CoverageReport, + thresholds: [String: ThresholdConfig]) -> [ThresholdResult] { + var results: [ThresholdResult] = [] + + for (targetName, config) in thresholds { + guard let target = coverage.targets.first(where: { $0.name == targetName }) else { + // Target not found in report, skip validation + continue + } + + let targetCoveragePercent = target.coverage * 100.0 + + // Validate minimum coverage for this target if configured + if let minCoverage = config.minCoverage { + if targetCoveragePercent < minCoverage { + let reason = String(format: "Target '%@' coverage %.2f%% is below minimum threshold %.2f%%", + targetName, targetCoveragePercent, minCoverage) + let details = ThresholdFailureDetails(expected: minCoverage, + actual: targetCoveragePercent, + targetName: targetName) + results.append(.fail(reason: reason, details: details)) + } else { + results.append(.pass) + } + } + } + + return results + } +} + +public extension ThresholdValidator { + enum ThresholdValidatorError: Errorable { + case validationFailed(results: [ThresholdResult]) + + public var printsHelp: Bool { false } + + public var errorDescription: String? { + switch self { + case .validationFailed(let results): + let failures = results.compactMap { result -> String? in + if case .fail(let reason, _) = result { + return reason + } + return nil + } + return failures.joined(separator: "\n") + } + } + } +} diff --git a/Sources/Shared/Config/Config+Thresholds.swift b/Sources/Shared/Config/Config+Thresholds.swift new file mode 100644 index 0000000..acef0ab --- /dev/null +++ b/Sources/Shared/Config/Config+Thresholds.swift @@ -0,0 +1,82 @@ +// +// Config+Thresholds.swift +// +// +// Created by Moritz Ellerbrock on 28.02.26. +// + +import Foundation + +public extension Config { + struct Thresholds: Codable, CustomStringConvertible { + public let global: Double? + public let targets: [String: Double]? + + public var description: String { + return """ + Global: \(global?.description ?? "N/A") + Targets: \(targets?.description ?? "N/A") + """ + } + } + + enum ThresholdValidationWarning: CustomStringConvertible { + case invalidGlobalThreshold(Double) + case invalidTargetThreshold(String, Double) + case redundantTargetThreshold(String, Double) + case emptyThresholdsConfiguration + + public var description: String { + switch self { + case .invalidGlobalThreshold(let value): + return "Global threshold \(value) is outside valid range (0-100)" + case .invalidTargetThreshold(let target, let value): + return "Threshold for target '\(target)' (\(value)) is outside valid range (0-100)" + case .redundantTargetThreshold(let target, let value): + return "Target '\(target)' has threshold \(value) which equals the global threshold (redundant configuration)" + case .emptyThresholdsConfiguration: + return "Thresholds configuration exists but contains no global or target-specific thresholds" + } + } + } + + /// Validates threshold configuration and returns any warnings + /// - Returns: Array of validation warnings (empty if no issues found) + func validateThresholds() -> [ThresholdValidationWarning] { + guard let thresholds = self.thresholds else { + return [] // No thresholds configured, nothing to validate + } + + var warnings: [ThresholdValidationWarning] = [] + + // Check if configuration is completely empty + if thresholds.global == nil && (thresholds.targets == nil || thresholds.targets?.isEmpty == true) { + warnings.append(.emptyThresholdsConfiguration) + return warnings + } + + // Validate global threshold range + if let global = thresholds.global { + if global < 0 || global > 100 { + warnings.append(.invalidGlobalThreshold(global)) + } + } + + // Validate target-specific thresholds + if let targets = thresholds.targets { + for (targetName, threshold) in targets { + // Check value range + if threshold < 0 || threshold > 100 { + warnings.append(.invalidTargetThreshold(targetName, threshold)) + } + + // Check for redundancy with global threshold + if let global = thresholds.global, threshold == global { + warnings.append(.redundantTargetThreshold(targetName, threshold)) + } + } + } + + return warnings + } +} diff --git a/Sources/Shared/Config/Config+Tool.swift b/Sources/Shared/Config/Config+Tool.swift index 9c0781e..f0d404a 100644 --- a/Sources/Shared/Config/Config+Tool.swift +++ b/Sources/Shared/Config/Config+Tool.swift @@ -31,6 +31,8 @@ public extension Config { return try? SlackSettings(values: settingsVault) case .archiverDB: return try? DBConfig(values: settingsVault) + case .threshold: + return try? ThresholdSettings(values: settingsVault) } } @@ -48,6 +50,7 @@ public extension Config.Tool { case githubExporter = "github_exporter" case htmlExporter = "html_exporter" case slack = "slack_reporter" + case threshold fileprivate var acceptedKeys: [String] { switch self { @@ -61,6 +64,8 @@ public extension Config.Tool { return ["format", "webhookVariable"] case .archiverDB: return ["hostname", "port", "name", "username", "password"] + case .threshold: + return ["min_coverage", "max_drop", "per_target_thresholds"] } } diff --git a/Sources/Shared/Config/Config.swift b/Sources/Shared/Config/Config.swift index b4b9aa7..46804d4 100644 --- a/Sources/Shared/Config/Config.swift +++ b/Sources/Shared/Config/Config.swift @@ -12,11 +12,12 @@ public struct Config: Codable, CustomStringConvertible { public let included: Included? public let filterXCResults: [String]? public let locations: Locations? + public let thresholds: Thresholds? private let tools: [Tool]? // public let workflow: Workflow? enum CodingKeys: String, CodingKey { - case excluded, included, archiver, locations, tools + case excluded, included, archiver, locations, thresholds, tools case filterXCResults = "filter_results" } @@ -24,12 +25,14 @@ public struct Config: Codable, CustomStringConvertible { included: Config.Included? = nil, filterXCResults: [String]? = nil, locations: Config.Locations? = nil, + thresholds: Config.Thresholds? = nil, tools: [Tool]? = nil) { self.excluded = excluded self.included = included self.filterXCResults = filterXCResults self.locations = locations + self.thresholds = thresholds self.tools = tools } @@ -56,6 +59,7 @@ public struct Config: Codable, CustomStringConvertible { included = try container.decodeIfPresent(Included.self, forKey: .included) locations = try container.decodeIfPresent(Locations.self, forKey: .locations) filterXCResults = try container.decodeIfPresent([String].self, forKey: .filterXCResults) + thresholds = try container.decodeIfPresent(Thresholds.self, forKey: .thresholds) tools = try container.decodeIfPresent([Tool].self, forKey: .tools) } @@ -65,6 +69,7 @@ public struct Config: Codable, CustomStringConvertible { try container.encodeIfPresent(excluded, forKey: .excluded) try container.encodeIfPresent(locations, forKey: .locations) try container.encodeIfPresent(filterXCResults, forKey: .filterXCResults) + try container.encodeIfPresent(thresholds, forKey: .thresholds) try container.encodeIfPresent(tools, forKey: .tools) } @@ -73,6 +78,7 @@ public struct Config: Codable, CustomStringConvertible { Excluded: \(excluded?.description ?? "N/A") Included: \(included?.description ?? "N/A") Locations: \(locations?.description ?? "N/A") + Thresholds: \(thresholds?.description ?? "N/A") """ } @@ -87,6 +93,9 @@ public struct Config: Codable, CustomStringConvertible { reportType: .markdown, archive: "Reports/Archive/") + let thresholds: Thresholds = .init(global: 80.0, + targets: ["MyApp": 85.0, "MyFramework": 75.0]) + let tools: [Tool] = [ Tool(Tool.ToolType.archiver, settingsVault: ["limit": "5"]), @@ -100,6 +109,7 @@ public struct Config: Codable, CustomStringConvertible { return .init(excluded: excluded, filterXCResults: filterXCResults, locations: locations, + thresholds: thresholds, tools: tools) } } diff --git a/Sources/Shared/Config/Settings/ThresholdSettings.swift b/Sources/Shared/Config/Settings/ThresholdSettings.swift new file mode 100644 index 0000000..e936063 --- /dev/null +++ b/Sources/Shared/Config/Settings/ThresholdSettings.swift @@ -0,0 +1,92 @@ +// +// ThresholdSettings.swift +// +// +// Created by Moritz Ellerbrock on 28.02.26. +// + +import Foundation + +public struct ThresholdConfig: Codable { + public let minCoverage: Double? + public let maxDrop: Double? + + public init(minCoverage: Double? = nil, maxDrop: Double? = nil) { + self.minCoverage = minCoverage + self.maxDrop = maxDrop + } +} + +public struct ThresholdSettings: SettingsObjectify { + private static let minCoverageKey: String = "min_coverage" + private static let maxDropKey: String = "max_drop" + private static let perTargetThresholdsKey: String = "per_target_thresholds" + + public let minCoverage: Double? + public let maxDrop: Double? + public let perTargetThresholds: [String: ThresholdConfig] + + public init(values: [String: String]) throws { + // Parse minCoverage + if let minCoverageString = values[Self.minCoverageKey], + let minCoverage = Double(minCoverageString) { + self.minCoverage = minCoverage + } else { + self.minCoverage = nil + } + + // Parse maxDrop + if let maxDropString = values[Self.maxDropKey], + let maxDrop = Double(maxDropString) { + self.maxDrop = maxDrop + } else { + self.maxDrop = nil + } + + // Parse perTargetThresholds (JSON encoded) + if let perTargetThresholdsString = values[Self.perTargetThresholdsKey], + let data = perTargetThresholdsString.data(using: .utf8), + let decoded = try? SingleDecoder.shared.decode([String: ThresholdConfig].self, from: data) { + self.perTargetThresholds = decoded + } else { + self.perTargetThresholds = [:] + } + } + + public func toDict() throws -> [String: String] { + var dict = [String: String]() + + if let minCoverage = minCoverage { + dict[Self.minCoverageKey] = "\(minCoverage)" + } + + if let maxDrop = maxDrop { + dict[Self.maxDropKey] = "\(maxDrop)" + } + + if !perTargetThresholds.isEmpty { + let jsonData = try SingleEncoder.shared.encode(perTargetThresholds) + if let json = String(data: jsonData, encoding: .utf8) { + dict[Self.perTargetThresholdsKey] = json + } + } + + return dict + } +} + +public extension ThresholdSettings { + enum ThresholdSettingsError: LocalizedError { + case missing(key: String) + case invalidValue(key: String, value: String) + + public var errorDescription: String? { + switch self { + case let .missing(key): + return "Threshold settings is missing \(key) key with value" + case let .invalidValue(key, value): + return "Threshold settings has invalid value '\(value)' for key \(key)" + } + } + } +} diff --git a/Sources/Shared/Coverage/ThresholdValidationResult.swift b/Sources/Shared/Coverage/ThresholdValidationResult.swift new file mode 100644 index 0000000..3eae669 --- /dev/null +++ b/Sources/Shared/Coverage/ThresholdValidationResult.swift @@ -0,0 +1,27 @@ +// +// ThresholdValidationResult.swift +// +// +// Created by Moritz Ellerbrock on 28.02.26. +// + +import Foundation + +/// Result of validating a single target against its threshold +public struct ThresholdValidationResult { + public let targetName: String + public let actualCoverage: Double + public let requiredThreshold: Double + public let passed: Bool + + public init(targetName: String, actualCoverage: Double, requiredThreshold: Double, passed: Bool) { + self.targetName = targetName + self.actualCoverage = actualCoverage + self.requiredThreshold = requiredThreshold + self.passed = passed + } + + public var actualCoveragePercentage: Double { + actualCoverage * 100.0 + } +}