diff --git a/README.md b/README.md index c0b641e..2c39679 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,93 @@ - [![๐Ÿงช Run Tests](https://github.com/Adyen/adyen-swift-public-api-diff/actions/workflows/run-tests.yml/badge.svg)](https://github.com/Adyen/adyen-swift-public-api-diff/actions/workflows/run-tests.yml) +[![๐Ÿงช Run Tests](https://github.com/Adyen/adyen-swift-public-api-diff/actions/workflows/run-tests.yml/badge.svg)](https://github.com/Adyen/adyen-swift-public-api-diff/actions/workflows/run-tests.yml) - # Swift Public API diff +# Swift Public API diff - This tool allows comparing 2 versions of a swift (sdk) project and lists all changes in a human readable way. +This tool allows comparing 2 versions of a swift (sdk) project and lists all changes in a human readable way. - It makes use of `.swiftinterface` files that get produced during the archiving of a swift project and parses them using [`swift-syntax`](https://github.com/swiftlang/swift-syntax). +It makes use of `.swiftinterface` files that get produced during the archiving of a swift project and parses them using [`swift-syntax`](https://github.com/swiftlang/swift-syntax). - ## Usage +## Usage + +### From Project to Output + +``` +USAGE: public-api-diff project --new --old [--scheme ] [--swift-interface-type ] [--output ] [--log-output ] [--log-level ] +OPTIONS: + --new Specify the updated version to compare to + --old Specify the old version to compare to + --scheme [Optional] Which scheme to build (Needed when + comparing 2 xcode projects) + --swift-interface-type + [Optional] Specify the type of .swiftinterface you + want to compare (public/private) (default: public) + --output [Optional] Where to output the result (File path) + --log-output + [Optional] Where to output the logs (File path) + --log-level [Optional] The log level to use during execution + (default: default) + -h, --help Show help information. ``` - USAGE: public-api-diff --new --old [--output ] [--log-output ] [--scheme ] - - OPTIONS: - --new Specify the updated version to compare to - --old Specify the old version to compare to - --output Where to output the result (File path) - --log-output - Where to output the logs (File path) - --scheme Which scheme to build (Needed when comparing 2 xcode projects) - -h, --help Show help information. - ``` + +#### Run as debug build +``` +# From Project to Output +swift run public-api-diff + project + --new "develop~https://github.com/Adyen/adyen-ios.git" + --old "5.12.0~https://github.com/Adyen/adyen-ios.git" +``` + +### From `.swiftinterface` to Output + +``` +USAGE: public-api-diff swift-interface --new --old [--target-name ] [--old-version-name ] [--new-version-name ] [--output ] [--log-output ] [--log-level ] + +OPTIONS: + --new Specify the updated .swiftinterface file to compare to + --old Specify the old .swiftinterface file to compare to + --target-name + [Optional] The name of your target/module to show in + the output + --old-version-name + [Optional] The name of your old version (e.g. v1.0 / + main) to show in the output + --new-version-name + [Optional] The name of your new version (e.g. v2.0 / + develop) to show in the output + --output [Optional] Where to output the result (File path) + --log-output + [Optional] Where to output the logs (File path) + --log-level [Optional] The log level to use during execution + (default: default) + -h, --help Show help information. +``` -### Run as debug build +#### Run as debug build ``` -swift run public-api-diff - --new "some/local/path" - --old "develop~https://github.com/some/repository" - --output "path/to/output.md" +# From Project to Output +swift run public-api-diff + swift-interface + --new "new/path/to/project.swiftinterface" + --old "old/path/to/project.swiftinterface" ``` -### How to create a release build +## How to create a release build ``` swift build --configuration release ``` -### Run release build +## Run release build ``` ./public-api-diff - --new "some/local/path" - --old "develop~https://github.com/some/repository" - --output "path/to/output.md" + project + --new "develop~https://github.com/Adyen/adyen-ios.git" + --old "5.12.0~https://github.com/Adyen/adyen-ios.git" + +./public-api-diff + swift-interface + --new "new/path/to/project.swiftinterface" + --old "old/path/to/project.swiftinterface" ``` # Alternatives diff --git a/Sources/ExecutableTargets/CommandLineTool/CommandLineTool+Extensions.swift b/Sources/ExecutableTargets/CommandLineTool/CommandLineTool+Extensions.swift new file mode 100644 index 0000000..efe2cc0 --- /dev/null +++ b/Sources/ExecutableTargets/CommandLineTool/CommandLineTool+Extensions.swift @@ -0,0 +1,25 @@ +import ArgumentParser + +import PADProjectBuilder +import PADLogging + +extension SwiftInterfaceType: ExpressibleByArgument { + public init?(argument: String) { + switch argument { + case "public": self = .public + case "private": self = .private + default: return nil + } + } +} + +extension LogLevel: ExpressibleByArgument { + public init?(argument: String) { + switch argument { + case "quiet": self = .quiet + case "default": self = .default + case "debug": self = .debug + default: return nil + } + } +} diff --git a/Sources/ExecutableTargets/CommandLineTool/CommandLineTool.swift b/Sources/ExecutableTargets/CommandLineTool/CommandLineTool.swift new file mode 100644 index 0000000..2f9d213 --- /dev/null +++ b/Sources/ExecutableTargets/CommandLineTool/CommandLineTool.swift @@ -0,0 +1,48 @@ +// +// Copyright (c) 2024 Adyen N.V. +// +// This file is open source and available under the MIT license. See the LICENSE file for more info. +// + +import ArgumentParser +import Foundation + +import PADCore +import PADLogging + +import PADSwiftInterfaceDiff +import PADProjectBuilder +import PADOutputGenerator +import PADPackageFileAnalyzer + +@main +struct PublicApiDiff: AsyncParsableCommand { + + static var configuration: CommandConfiguration = .init( + commandName: "public-api-diff", + subcommands: [ + ProjectToOutputCommand.self, + SwiftInterfaceToOutputCommand.self + ] + ) + + public func run() async throws { + fatalError("No sub command provided") + } +} + +extension PublicApiDiff { + + static func logger( + with logLevel: LogLevel, + logOutputFilePath: String? + ) -> any Logging { + var loggers = [any Logging]() + if let logOutputFilePath { + loggers += [LogFileLogger(outputFilePath: logOutputFilePath)] + } + loggers += [SystemLogger().withLogLevel(logLevel)] + + return LoggingGroup(with: loggers) + } +} diff --git a/Sources/ExecutableTargets/CommandLineTool/ProjectToOutputCommand.swift b/Sources/ExecutableTargets/CommandLineTool/ProjectToOutputCommand.swift new file mode 100644 index 0000000..256675c --- /dev/null +++ b/Sources/ExecutableTargets/CommandLineTool/ProjectToOutputCommand.swift @@ -0,0 +1,202 @@ +import ArgumentParser +import Foundation + +import PADCore +import PADLogging + +import PADSwiftInterfaceDiff +import PADProjectBuilder +import PADOutputGenerator +import PADPackageFileAnalyzer + +/// Command that analyzes the differences between an old and new project and produces a human readable output +struct ProjectToOutputCommand: AsyncParsableCommand { + + static var configuration: CommandConfiguration = .init(commandName: "project") + + /// The representation of the new/updated project source + @Option(help: "Specify the updated version to compare to") + public var new: String + + /// The representation of the old/reference project source + @Option(help: "Specify the old version to compare to") + public var old: String + + /// The (optional) scheme to build + /// + /// Needed when comparing 2 xcode projects + @Option(help: "[Optional] Which scheme to build (Needed when comparing 2 xcode projects)") + public var scheme: String? + + @Option(help: "[Optional] Specify the type of .swiftinterface you want to compare (public/private)") + public var swiftInterfaceType: SwiftInterfaceType = .public + + /// The (optional) output file path + /// + /// If not defined the output will be printed to the console + @Option(help: "[Optional] Where to output the result (File path)") + public var output: String? + + /// The (optional) path to the log output file + @Option(help: "[Optional] Where to output the logs (File path)") + public var logOutput: String? + + @Option(help: "[Optional] The log level to use during execution") + public var logLevel: LogLevel = .default + + /// Entry point of the command line tool + public func run() async throws { + + let projectType: ProjectType = { + if let scheme { return .xcodeProject(scheme: scheme) } + return .swiftPackage + }() + + let logger = PublicApiDiff.logger(with: logLevel, logOutputFilePath: logOutput) + + do { + var warnings = [String]() + var projectChanges = [Change]() + + let oldSource: ProjectSource = try .from(old) + let newSource: ProjectSource = try .from(new) + + // MARK: - Producing .swiftinterface files + + let projectBuilderResult = try await Self.buildProject( + oldSource: oldSource, + newSource: newSource, + projectType: projectType, + swiftInterfaceType: swiftInterfaceType, + logger: logger + ) + + // MARK: - Analyzing .swiftinterface files + + let swiftInterfaceChanges = try await Self.analyzeSwiftInterfaceFiles( + swiftInterfaceFiles: projectBuilderResult.swiftInterfaceFiles, + logger: logger + ) + + // MARK: - Analyzing Package.swift + + try Self.analyzeProject( + ofType: projectType, + projectDirectories: projectBuilderResult.projectDirectories, + changes: &projectChanges, + warnings: &warnings, + logger: logger + ) + + // MARK: - Merging Changes + + var changes = swiftInterfaceChanges + if !projectChanges.isEmpty { + changes["Package.swift"] = projectChanges + } + + // MARK: - Generate Output + + let generatedOutput = try Self.generateOutput( + for: changes, + warnings: warnings, + allTargets: projectBuilderResult.swiftInterfaceFiles.map(\.name).sorted(), + oldVersionName: oldSource.description, + newVersionName: newSource.description + ) + + // MARK: - + + if let output { + try FileManager.default.write(generatedOutput, to: output) + } else { + // We're not using a logger here as we always want to have it printed if no output was specified + print(generatedOutput) + } + + logger.log("โœ… Success", from: "Main") + } catch { + logger.log("๐Ÿ’ฅ \(error.localizedDescription)", from: "Main") + } + } +} + +// MARK: - Privates + +private extension ProjectToOutputCommand { + + static func buildProject( + oldSource: ProjectSource, + newSource: ProjectSource, + projectType: ProjectType, + swiftInterfaceType: SwiftInterfaceType, + logger: any Logging + ) async throws -> ProjectBuilder.Result { + + let projectBuilder = ProjectBuilder( + projectType: projectType, + swiftInterfaceType: swiftInterfaceType, + logger: logger + ) + + return try await projectBuilder.build( + oldSource: oldSource, + newSource: newSource + ) + } + + static func analyzeProject( + ofType projectType: ProjectType, + projectDirectories: (old: URL, new: URL), + changes: inout [Change], + warnings: inout [String], + logger: any Logging + ) throws { + switch projectType { + case .swiftPackage: + let swiftPackageFileAnalyzer = SwiftPackageFileAnalyzer( + logger: logger + ) + let swiftPackageAnalysis = try swiftPackageFileAnalyzer.analyze( + oldProjectUrl: projectDirectories.old, + newProjectUrl: projectDirectories.new + ) + + warnings = swiftPackageAnalysis.warnings + changes = swiftPackageAnalysis.changes + case .xcodeProject: + warnings = [] + changes = [] + break // Nothing to do + } + } + + static func analyzeSwiftInterfaceFiles( + swiftInterfaceFiles: [SwiftInterfaceFile], + logger: any Logging + ) async throws -> [String: [Change]] { + let swiftInterfaceDiff = SwiftInterfaceDiff(logger: logger) + + return try await swiftInterfaceDiff.run( + with: swiftInterfaceFiles + ) + } + + static func generateOutput( + for changes: [String: [Change]], + warnings: [String], + allTargets: [String], + oldVersionName: String, + newVersionName: String + ) throws -> String { + let outputGenerator: any OutputGenerating = MarkdownOutputGenerator() + + return try outputGenerator.generate( + from: changes, + allTargets: allTargets, + oldVersionName: oldVersionName, + newVersionName: newVersionName, + warnings: warnings + ) + } +} diff --git a/Sources/ExecutableTargets/CommandLineTool/SwiftInterfaceToOutputCommand.swift b/Sources/ExecutableTargets/CommandLineTool/SwiftInterfaceToOutputCommand.swift new file mode 100644 index 0000000..8b4d9ce --- /dev/null +++ b/Sources/ExecutableTargets/CommandLineTool/SwiftInterfaceToOutputCommand.swift @@ -0,0 +1,117 @@ +import ArgumentParser +import Foundation + +import PADCore +import PADLogging + +import PADSwiftInterfaceDiff +import PADProjectBuilder +import PADOutputGenerator +import PADPackageFileAnalyzer + +/// Command that analyzes the differences between an old and new `.swiftinterface` file and produces a human readable output +struct SwiftInterfaceToOutputCommand: AsyncParsableCommand { + + static var configuration: CommandConfiguration = .init(commandName: "swift-interface") + + /// The representation of the new/updated project source + @Option(help: "Specify the updated .swiftinterface file to compare to") + public var new: String + + /// The representation of the old/reference project source + @Option(help: "Specify the old .swiftinterface file to compare to") + public var old: String + + /// The name of the target/module to show in the output + @Option(help: "[Optional] The name of your target/module to show in the output") + public var targetName: String? + + @Option(help: "[Optional] The name of your old version (e.g. v1.0 / main) to show in the output") + public var oldVersionName: String? + + @Option(help: "[Optional] The name of your new version (e.g. v2.0 / develop) to show in the output") + public var newVersionName: String? + + /// The (optional) output file path + /// + /// If not defined the output will be printed to the console + @Option(help: "[Optional] Where to output the result (File path)") + public var output: String? + + /// The (optional) path to the log output file + @Option(help: "[Optional] Where to output the logs (File path)") + public var logOutput: String? + + @Option(help: "[Optional] The log level to use during execution") + public var logLevel: LogLevel = .default + + /// Entry point of the command line tool + public func run() async throws { + + let logger = PublicApiDiff.logger(with: logLevel, logOutputFilePath: logOutput) + + do { + // MARK: - Analyzing .swiftinterface files + + let swiftInterfaceChanges = try await Self.analyzeSwiftInterfaceFiles( + swiftInterfaceFiles: [.init(name: targetName ?? "", oldFilePath: old, newFilePath: new)], + logger: logger + ) + + // MARK: - Generate Output + + let generatedOutput = try Self.generateOutput( + for: swiftInterfaceChanges, + warnings: [], + allTargets: targetName.map { [$0] }, + oldVersionName: oldVersionName, + newVersionName: newVersionName + ) + + // MARK: - + + if let output { + try FileManager.default.write(generatedOutput, to: output) + } else { + // We're not using a logger here as we always want to have it printed if no output was specified + print(generatedOutput) + } + + logger.log("โœ… Success", from: "Main") + } catch { + logger.log("๐Ÿ’ฅ \(error.localizedDescription)", from: "Main") + } + } +} + +private extension SwiftInterfaceToOutputCommand { + + static func analyzeSwiftInterfaceFiles( + swiftInterfaceFiles: [SwiftInterfaceFile], + logger: any Logging + ) async throws -> [String: [Change]] { + let swiftInterfaceDiff = SwiftInterfaceDiff(logger: logger) + + return try await swiftInterfaceDiff.run( + with: swiftInterfaceFiles + ) + } + + static func generateOutput( + for changes: [String: [Change]], + warnings: [String], + allTargets: [String]?, + oldVersionName: String?, + newVersionName: String? + ) throws -> String { + let outputGenerator: any OutputGenerating = MarkdownOutputGenerator() + + return try outputGenerator.generate( + from: changes, + allTargets: allTargets, + oldVersionName: oldVersionName, + newVersionName: newVersionName, + warnings: warnings + ) + } +} diff --git a/Sources/PublicModules/PADOutputGenerator/MarkdownOutputGenerator.swift b/Sources/PublicModules/PADOutputGenerator/MarkdownOutputGenerator.swift index c7798a8..5fc2948 100644 --- a/Sources/PublicModules/PADOutputGenerator/MarkdownOutputGenerator.swift +++ b/Sources/PublicModules/PADOutputGenerator/MarkdownOutputGenerator.swift @@ -15,9 +15,9 @@ public struct MarkdownOutputGenerator: OutputGenerating { /// Generates human readable output from the provided information public func generate( from changesPerTarget: [String: [Change]], - allTargets: [String], - oldVersionName: String, - newVersionName: String, + allTargets: [String]?, + oldVersionName: String?, + newVersionName: String?, warnings: [String] ) -> String { @@ -26,10 +26,14 @@ public struct MarkdownOutputGenerator: OutputGenerating { var lines = [ Self.title(changesPerTarget: changesPerTarget), - Self.repoInfo(oldVersionName: oldVersionName, newVersionName: newVersionName), - separator ] + if let oldVersionName, let newVersionName { + lines += [Self.repoInfo(oldVersionName: oldVersionName, newVersionName: newVersionName)] + } + + lines += [separator] + if !warnings.isEmpty { lines += Self.warningInfo(for: warnings) + [separator] } @@ -38,9 +42,11 @@ public struct MarkdownOutputGenerator: OutputGenerating { lines += changes + [separator] } - lines += [ - Self.analyzedModulesInfo(allTargets: allTargets) - ] + if let allTargets { + lines += [ + Self.analyzedModulesInfo(allTargets: allTargets) + ] + } return lines.joined(separator: "\n") } diff --git a/Sources/PublicModules/PADOutputGenerator/OutputGenerating.swift b/Sources/PublicModules/PADOutputGenerator/OutputGenerating.swift index 8c54003..baf9e8c 100644 --- a/Sources/PublicModules/PADOutputGenerator/OutputGenerating.swift +++ b/Sources/PublicModules/PADOutputGenerator/OutputGenerating.swift @@ -22,9 +22,9 @@ public protocol OutputGenerating { /// - Returns: An output of type ``OutputType`` func generate( from changesPerTarget: [String: [Change]], - allTargets: [String], - oldVersionName: String, - newVersionName: String, + allTargets: [String]?, + oldVersionName: String?, + newVersionName: String?, warnings: [String] ) throws -> OutputType } diff --git a/Tests/UnitTests/Utilities/MockPipelineModules/MockOutputGenerator.swift b/Tests/UnitTests/Utilities/MockPipelineModules/MockOutputGenerator.swift index dd9ba64..e1d9a63 100644 --- a/Tests/UnitTests/Utilities/MockPipelineModules/MockOutputGenerator.swift +++ b/Tests/UnitTests/Utilities/MockPipelineModules/MockOutputGenerator.swift @@ -10,13 +10,13 @@ import XCTest struct MockOutputGenerator: OutputGenerating { - var onGenerate: ([String: [Change]], [String], String, String, [String]) throws -> String + var onGenerate: ([String: [Change]], [String]?, String?, String?, [String]) throws -> String func generate( from changesPerTarget: [String: [Change]], - allTargets: [String], - oldVersionName: String, - newVersionName: String, + allTargets: [String]?, + oldVersionName: String?, + newVersionName: String?, warnings: [String] ) throws -> String { try onGenerate(changesPerTarget, allTargets, oldVersionName, newVersionName, warnings)