diff --git a/Sources/NnexKit/Formula/FormulaManager.swift b/Sources/NnexKit/Formula/FormulaManager.swift new file mode 100644 index 0000000..f0bbd73 --- /dev/null +++ b/Sources/NnexKit/Formula/FormulaManager.swift @@ -0,0 +1,29 @@ +// +// FormulaManager.swift +// nnex +// +// Created by Nikolai Nobadi on 12/14/25. +// + +public struct FormulaManager { + private let fileSystem: any FileSystem + + public init(fileSystem: any FileSystem) { + self.fileSystem = fileSystem + } +} + + +// MARK: - Actions +public extension FormulaManager { + func resolveFormulaFile(formula: HomebrewFormula, tapFolder: any Directory, contents: String) throws -> String { + let fileName = "\(formula.name).rb" + let formulaFolder = try tapFolder.createSubfolderIfNeeded(named: "Formula") + + if formulaFolder.containsFile(named: fileName) { + try formulaFolder.deleteFile(named: fileName) + } + + return try formulaFolder.createFile(named: fileName, contents: contents) + } +} diff --git a/Sources/nnex/Commands/Brew/Publish.swift b/Sources/nnex/Commands/Brew/Publish.swift index 8b1bea1..e0839cd 100644 --- a/Sources/nnex/Commands/Brew/Publish.swift +++ b/Sources/nnex/Commands/Brew/Publish.swift @@ -34,13 +34,17 @@ extension Nnex.Brew { @Flag(name: .customLong("skip-tests"), help: "Skips running tests before publishing.") var skipTests = false + @Flag(name: .long, help: "Simulates the publish process without making any changes to files or remote repositories.") + var dryRun = false + func run() throws { let shell = Nnex.makeShell() let context = try Nnex.makeContext() - let gitHandler = Nnex.makeGitHandler() let fileSystem = Nnex.makeFileSystem() + let gitHandler = makePublishGitHandler(dryRun: dryRun) let resolvedBuildType = buildType ?? context.loadDefaultBuildType() - let delegate = makePublishDelegate(shell: shell, gitHandler: gitHandler, fileSystem: fileSystem, context: context) + let formulaFileService = makeFormulaFileService(dryRun: dryRun, fileSystem: fileSystem) + let delegate = makePublishDelegate(shell: shell, gitHandler: gitHandler, fileSystem: fileSystem, formulaFileService: formulaFileService, context: context) let coordinator = PublishCoordinator(shell: shell, gitHandler: gitHandler, fileSystem: fileSystem, delegate: delegate) try coordinator.publish(projectPath: path, buildType: resolvedBuildType, notes: notes, notesFilePath: notesFile, commitMessage: message, skipTests: skipTests, versionInfo: version) @@ -63,7 +67,21 @@ private extension Nnex.Brew.Publish { return .init(shell: shell, picker: picker, fileSystem: fileSystem, delegate: artifactDelegate) } - func makePublishDelegate(shell: any NnexShell, gitHandler: any GitHandler, fileSystem: any FileSystem, context: NnexContext) -> any PublishDelegate { + func makePublishGitHandler(dryRun: Bool) -> any GitHandler { + let gitHandler = Nnex.makeGitHandler() + + return dryRun ? DryRunPublishGitHandler(gitHandler: gitHandler) : gitHandler + } + + func makeFormulaFileService(dryRun: Bool, fileSystem: any FileSystem) -> any FormulaFileService { + if dryRun { + return DryRunFormulaFileService() + } + + return FormulaManager(fileSystem: fileSystem) + } + + func makePublishDelegate(shell: any NnexShell, gitHandler: any GitHandler, fileSystem: any FileSystem, formulaFileService: any FormulaFileService, context: NnexContext) -> PublishDelegateAdapter { let picker = Nnex.makePicker() let folderBrowser = Nnex.makeFolderBrowser(picker: picker, fileSystem: fileSystem) let dateProvider = DefaultDateProvider() @@ -71,17 +89,19 @@ private extension Nnex.Brew.Publish { let versionService = AutoVersionHandler(shell: shell, fileSystem: fileSystem) let versionController = VersionNumberController(shell: shell, picker: picker, gitHandler: gitHandler, fileSystem: fileSystem, versionService: versionService) + let buildController = makeBuildController(shell: shell, picker: picker, fileSystem: fileSystem, folderBrowser: folderBrowser) let artifactController = makeArtifactController(shell: shell, picker: picker, fileSystem: fileSystem, loader: loader, buildController: buildController) let releaseController = GithubReleaseController(picker: picker, gitHandler: gitHandler, fileSystem: fileSystem, dateProvider: dateProvider, folderBrowser: folderBrowser) - let publishController = FormulaPublishController(picker: picker, gitHandler: gitHandler, fileSystem: fileSystem, store: loader) + let publishController = FormulaPublishController(picker: picker, gitHandler: gitHandler, fileSystem: fileSystem, store: loader, formulaFileService: formulaFileService) - return PublishDelegateAdapter(versionController: versionController, artifactController: artifactController, releaseController: releaseController, publishController: publishController) + return .init(versionController: versionController, artifactController: artifactController, releaseController: releaseController, publishController: publishController) } } // MARK: - Extension Dependencies +extension FormulaManager: FormulaFileService { } extension AutoVersionHandler: VersionNumberService { } extension ReleaseVersionInfo: ExpressibleByArgument { } extension ReleaseVersionInfo.VersionPart: ExpressibleByArgument { } diff --git a/Sources/nnex/Publish/Adapters/DryRunFormulaFileService.swift b/Sources/nnex/Publish/Adapters/DryRunFormulaFileService.swift new file mode 100644 index 0000000..7f2771b --- /dev/null +++ b/Sources/nnex/Publish/Adapters/DryRunFormulaFileService.swift @@ -0,0 +1,27 @@ +// +// DryRunFormulaFileService.swift +// nnex +// +// Created by Nikolai Nobadi on 12/14/25. +// + +import NnexKit + +struct DryRunFormulaFileService: FormulaFileService { + func resolveFormulaFile(formula: HomebrewFormula, tapFolder: any Directory, contents: String) throws -> FilePath { + let fileName = "\(formula.name).rb" + print( + """ + \("Formula File Details".underline) + fileName: \(fileName) + filePath (if created): \(tapFolder.path.appendingPathComponent("Formula/\(fileName)")) + + \("Formula Contents".underline) + \(contents) + + """ + ) + + return "No formula file was created, and any previous formula file for \(formula.name) remains unmodified." + } +} diff --git a/Sources/nnex/Publish/Adapters/DryRunPublishGitHandler.swift b/Sources/nnex/Publish/Adapters/DryRunPublishGitHandler.swift new file mode 100644 index 0000000..367e0cb --- /dev/null +++ b/Sources/nnex/Publish/Adapters/DryRunPublishGitHandler.swift @@ -0,0 +1,72 @@ +// +// DryRunPublishGitHandler.swift +// nnex +// +// Created by Nikolai Nobadi on 12/14/25. +// + +import NnexKit +import GitShellKit + +struct DryRunPublishGitHandler { + private let gitHandler: any GitHandler + + init(gitHandler: any GitHandler) { + self.gitHandler = gitHandler + } +} + + +// MARK: - GitHandler +extension DryRunPublishGitHandler: GitHandler { + func ghVerification() throws { + return try gitHandler.ghVerification() + } + + func gitInit(path: String) throws { + return try gitHandler.gitInit(path: path) + } + + func getRemoteURL(path: String) throws -> String { + return try gitHandler.getRemoteURL(path: path) + } + + func commitAndPush(message: String, path: String) throws { + print("prepering to commit at \(path.underline), message: \(message.yellow)") + } + + func getPreviousReleaseVersion(path: String) throws -> String { + return try gitHandler.getPreviousReleaseVersion(path: path) + } + + func remoteRepoInit(tapName: String, path: String, projectDetails: String, visibility: RepoVisibility) throws -> String { + return try gitHandler.remoteRepoInit(tapName: tapName, path: path, projectDetails: projectDetails, visibility: visibility) + } + + func createNewRelease(version: String, archivedBinaries: [ArchivedBinary], releaseNoteInfo: ReleaseNoteInfo, path: String) throws -> [String] { + print( + """ + + \("New Release Details".underline) + version: \(version) + archivedBinaryCount: \(archivedBinaries.count) + projectFolderPath: \(path) + \(releaseNoteInfo.detailText) + + """ + ) + return [] + } +} + + +// MARK: - Extension Dependencies +private extension ReleaseNoteInfo { + var detailText: String { + if isFromFile { + return "releaseNotesFilePath: \(content)" + } + + return "releaseNotes: \(content)" + } +} diff --git a/Sources/nnex/Publish/Controllers/FormulaPublishController.swift b/Sources/nnex/Publish/Controllers/FormulaPublishController.swift index c6538cc..429f819 100644 --- a/Sources/nnex/Publish/Controllers/FormulaPublishController.swift +++ b/Sources/nnex/Publish/Controllers/FormulaPublishController.swift @@ -12,12 +12,14 @@ struct FormulaPublishController { private let gitHandler: any GitHandler private let fileSystem: any FileSystem private let store: any PublishInfoStore + private let formulaFileService: any FormulaFileService - init(picker: any NnexPicker, gitHandler: any GitHandler, fileSystem: any FileSystem, store: any PublishInfoStore) { + init(picker: any NnexPicker, gitHandler: any GitHandler, fileSystem: any FileSystem, store: any PublishInfoStore, formulaFileService: any FormulaFileService) { self.store = store self.picker = picker self.gitHandler = gitHandler self.fileSystem = fileSystem + self.formulaFileService = formulaFileService } } @@ -26,14 +28,9 @@ struct FormulaPublishController { extension FormulaPublishController { func publishFormula(projectFolder: any Directory, info: FormulaPublishInfo, commitMessage: String?) throws { let formula = try getFormula(projectFolder: projectFolder, skipTests: true) - let content = try makeFormulaContent(formula: formula, info: info) + let contents = try makeFormulaContent(formula: formula, info: info) let tapFolder = try fileSystem.directory(at: formula.tapLocalPath) - let fileName = "\(formula.name).rb" - let formulaFolder = try tapFolder.createSubfolderIfNeeded(named: "Formula") - - try deleteOldFormula(named: fileName, from: formulaFolder) - - let filePath = try formulaFolder.createFile(named: fileName, contents: content) + let filePath = try formulaFileService.resolveFormulaFile(formula: formula, tapFolder: tapFolder, contents: contents) print("New formula created at \(filePath)") @@ -46,6 +43,18 @@ extension FormulaPublishController { // MARK: - Private Methods private extension FormulaPublishController { + func getMessage(message: String?) throws -> String? { + if let message { + return message + } + + guard picker.getPermission(prompt: "\nWould you like to commit and push the tap to \("GitHub".green)?") else { + return nil + } + + return try picker.getRequiredInput(prompt: "Enter your commit message.") + } + func getFormula(projectFolder: any Directory, skipTests: Bool) throws -> HomebrewFormula { let allTaps = try store.loadTaps() let tap = try getTap(allTaps: allTaps, projectName: projectFolder.name) @@ -177,22 +186,11 @@ private extension FormulaPublishController { ) } } - - func deleteOldFormula(named name: String, from folder: any Directory) throws { - if folder.containsFile(named: name) { - try folder.deleteFile(named: name) - } - } - - func getMessage(message: String?) throws -> String? { - if let message { - return message - } +} - guard picker.getPermission(prompt: "\nWould you like to commit and push the tap to \("GitHub".green)?") else { - return nil - } - return try picker.getRequiredInput(prompt: "Enter your commit message.") - } +// MARK: - Dependencies +protocol FormulaFileService { + typealias FilePath = String + func resolveFormulaFile(formula: HomebrewFormula, tapFolder: any Directory, contents: String) throws -> FilePath } diff --git a/Tests/nnexTests/IntegrationTests/PublishTests/PublishTests.swift b/Tests/nnexTests/IntegrationTests/PublishTests/PublishTests.swift index c693dd6..b78ba18 100644 --- a/Tests/nnexTests/IntegrationTests/PublishTests/PublishTests.swift +++ b/Tests/nnexTests/IntegrationTests/PublishTests/PublishTests.swift @@ -231,9 +231,9 @@ extension PublishTests { let gitHandler = MockGitHandler(assetURL: assetURL) let shell = createMockShell(includeTestCommand: false, shouldThrowError: true) let factory = MockContextFactory(gitHandler: gitHandler, shell: shell) - + try createTestTapAndFormula(factory: factory, testCommand: .defaultCommand) - + do { try runCommand(factory, version: .version(versionNumber), message: commitMessage, notes: releaseNotes) Issue.record("Expected an error to be thrown") @@ -242,6 +242,84 @@ extension PublishTests { } +// MARK: - Dry Run Tests +extension PublishTests { + @Test("Does not create formula file when dry-run flag is enabled") + func dryRunDoesNotCreateFormulaFile() throws { + let gitHandler = MockGitHandler(assetURL: assetURL) + let shell = createMockShell() + let factory = MockContextFactory(gitHandler: gitHandler, shell: shell) + + try createTestTapAndFormula(factory: factory) + try runCommand(factory, version: .version(versionNumber), message: commitMessage, notes: releaseNotes, dryRun: true) + + let tapFolder = try Folder(path: tapFolder.path) + + #expect(tapFolder.containsSubfolder(named: "Formula") == false) + } + + @Test("Does not delete existing formula file when dry-run flag is enabled") + func dryRunDoesNotDeleteExistingFormulaFile() throws { + let existingFormulaContent = "class OldFormula < Formula\nend" + let gitHandler = MockGitHandler(assetURL: assetURL) + let shell = createMockShell() + let factory = MockContextFactory(gitHandler: gitHandler, shell: shell) + + try createTestTapAndFormula(factory: factory) + + let formulaFolder = try tapFolder.createSubfolder(named: "Formula") + let formulaFile = try formulaFolder.createFile(named: formulaFileName) + try formulaFile.write(existingFormulaContent) + + try runCommand(factory, version: .version(versionNumber), message: commitMessage, notes: releaseNotes, dryRun: true) + + let updatedFormulaFile = try formulaFolder.file(named: formulaFileName) + let updatedContent = try updatedFormulaFile.readAsString() + + #expect(updatedContent == existingFormulaContent) + } + + @Test("Does not commit and push when dry-run flag is enabled") + func dryRunDoesNotCommitAndPush() throws { + let gitHandler = MockGitHandler(assetURL: assetURL) + let shell = createMockShell() + let factory = MockContextFactory(gitHandler: gitHandler, shell: shell) + + try createTestTapAndFormula(factory: factory) + try runCommand(factory, version: .version(versionNumber), message: commitMessage, notes: releaseNotes, dryRun: true) + + #expect(gitHandler.message == nil) + } + + @Test("Does not create GitHub release when dry-run flag is enabled") + func dryRunDoesNotCreateRelease() throws { + let gitHandler = MockGitHandler(assetURL: assetURL) + let shell = createMockShell() + let factory = MockContextFactory(gitHandler: gitHandler, shell: shell) + + try createTestTapAndFormula(factory: factory) + try runCommand(factory, version: .version(versionNumber), message: commitMessage, notes: releaseNotes, dryRun: true) + + #expect(gitHandler.releaseVersion == nil) + #expect(gitHandler.releaseNoteInfo == nil) + } + + @Test("Dry-run still performs verification checks") + func dryRunPerformsVerificationChecks() throws { + let gitHandler = MockGitHandler(assetURL: assetURL, ghIsInstalled: false) + let shell = createMockShell() + let factory = MockContextFactory(gitHandler: gitHandler, shell: shell) + + try createTestTapAndFormula(factory: factory) + + do { + try runCommand(factory, version: .version(versionNumber), message: commitMessage, notes: releaseNotes, dryRun: true) + Issue.record("Expected an error to be thrown for missing GitHub CLI") + } catch { } + } +} + + // MARK: - Input Provided from User extension PublishTests { // @Test("Publishes a binary to Homebrew and verifies the formula file when infomation must be input and file path for release notes is input.") @@ -289,33 +367,37 @@ extension PublishTests { // MARK: - Run Command private extension PublishTests { - func runCommand(_ factory: MockContextFactory, version: ReleaseVersionInfo? = nil, message: String? = nil, notes: String? = nil, notesFile: String? = nil, skipTests: Bool = false, buildType: BuildType? = nil) throws { + func runCommand(_ factory: MockContextFactory, version: ReleaseVersionInfo? = nil, message: String? = nil, notes: String? = nil, notesFile: String? = nil, skipTests: Bool = false, buildType: BuildType? = nil, dryRun: Bool = false) throws { var args = ["brew", "publish", "-p", projectFolder.path] - + if let version { args.append(contentsOf: ["-v", version.arg]) } - + if let message { args.append(contentsOf: ["-m", message]) } - + if let notes { args.append(contentsOf: ["-n", notes]) } - + if let notesFile { args.append(contentsOf: ["-F", notesFile]) } - + if skipTests { args.append("--skip-tests") } - + if let buildType { args.append(contentsOf: ["-b", buildType.rawValue]) } - + + if dryRun { + args.append("--dry-run") + } + try Nnex.testRun(contextFactory: factory, args: args) } } diff --git a/Tests/nnexTests/UnitTests/FormulaPublishControllerTests.swift b/Tests/nnexTests/UnitTests/FormulaPublishControllerTests.swift index 0b50474..b47c11e 100644 --- a/Tests/nnexTests/UnitTests/FormulaPublishControllerTests.swift +++ b/Tests/nnexTests/UnitTests/FormulaPublishControllerTests.swift @@ -16,11 +16,12 @@ import NnexSharedTestHelpers struct FormulaPublishControllerTests { @Test("Starting values empty") func startingValues() { - let (_, store, _) = makeSUT() + let (_, delegate, _) = makeSUT() - #expect(store.updatedFormula == nil) - #expect(store.savedFormula == nil) - #expect(store.savedTap == nil) + #expect(delegate.savedTap == nil) + #expect(delegate.savedFormula == nil) + #expect(delegate.updatedFormula == nil) + #expect(delegate.formulaFileData == nil) } } @@ -35,11 +36,11 @@ extension FormulaPublishControllerTests { let formula = makeFormula(name: "tool", tapPath: tapPath, localProjectPath: "") let tap = HomebrewTap(name: "Tap", localPath: tapPath, remotePath: "", formulas: [formula]) let info = makePublishInfo(assetURLs: ["https://example.com/tool.tar.gz"]) - let (sut, store, project) = makeSUT(taps: [tap], directoryMap: [tapPath: tapDirectory]) + let (sut, delegate, project) = makeSUT(taps: [tap], directoryMap: [tapPath: tapDirectory]) try sut.publishFormula(projectFolder: project, info: info, commitMessage: "update formula") - #expect(store.updatedFormula?.localProjectPath == project.path) + #expect(delegate.updatedFormula?.localProjectPath == project.path) #expect(formulaFolder.containsFile(named: "tool.rb")) } @@ -53,15 +54,16 @@ extension FormulaPublishControllerTests { let armArchive = ArchivedBinary(originalPath: "/tmp/.build/arm64-apple-macosx/release/tool", archivePath: "/tmp/tool-arm64.tar.gz", sha256: "arm") let intelArchive = ArchivedBinary(originalPath: "/tmp/.build/x86_64-apple-macosx/release/tool", archivePath: "/tmp/tool-x86_64.tar.gz", sha256: "intel") let info = FormulaPublishInfo(version: "1.0.0", installName: "tool", assetURLs: ["arm-url", "intel-url"], archives: [armArchive, intelArchive]) - let (sut, _, project) = makeSUT(taps: [tap], directoryMap: [tapPath: tapDirectory]) + let (sut, delegate, project) = makeSUT(taps: [tap], directoryMap: [tapPath: tapDirectory]) try sut.publishFormula(projectFolder: project, info: info, commitMessage: nil) - let content = try formulaFolder.readFile(named: "tool.rb") - #expect(content.contains("arm-url")) - #expect(content.contains("intel-url")) - #expect(content.contains("sha256 \"arm\"")) - #expect(content.contains("sha256 \"intel\"")) + let data = try #require(delegate.formulaFileData) + + #expect(data.formula.name == formula.name) + #expect(data.tapFolder.path == formula.tapLocalPath) + #expect(data.contents.contains(armArchive.sha256)) + #expect(data.contents.contains(intelArchive.sha256)) } } @@ -77,7 +79,7 @@ extension FormulaPublishControllerTests { let project = MockDirectory(path: "/projects/tool", containedFiles: ["LICENSE"]) project.fileContents["LICENSE"] = "MIT License" let info = makePublishInfo(assetURLs: ["https://example.com/tool.tar.gz"]) - let (sut, store, projectFolder) = makeSUT( + let (sut, delegate, projectFolder) = makeSUT( taps: [tap1, tap2], inputResults: ["A tool"], selectionIndex: 1, @@ -89,13 +91,13 @@ extension FormulaPublishControllerTests { try sut.publishFormula(projectFolder: projectFolder, info: info, commitMessage: nil) - let savedFormula = try #require(store.savedFormula) + let savedFormula = try #require(delegate.savedFormula) #expect(savedFormula.name == projectFolder.name) #expect(savedFormula.details == "A tool") #expect(savedFormula.homepage == "https://example.com/repo.git") #expect(savedFormula.license == "MIT") #expect(savedFormula.testCommand == nil) - #expect(store.savedTap?.name == tap2.name) + #expect(delegate.savedTap?.name == tap2.name) } } @@ -111,13 +113,13 @@ extension FormulaPublishControllerTests { let tap = HomebrewTap(name: "Tap", localPath: tapPath, remotePath: "", formulas: [formula]) let archives = [ArchivedBinary(originalPath: "/tmp/tool", archivePath: "/tmp/tool.tar.gz", sha256: "abc123")] let info = FormulaPublishInfo(version: "1.0.0", installName: "tool", assetURLs: [], archives: archives) - let (sut, store, project) = makeSUT(taps: [tap], directoryMap: [tapPath: tapDirectory]) + let (sut, delegate, project) = makeSUT(taps: [tap], directoryMap: [tapPath: tapDirectory]) #expect(throws: NnexError.missingSha256) { try sut.publishFormula(projectFolder: project, info: info, commitMessage: nil) } - #expect(store.updatedFormula == nil) + #expect(delegate.updatedFormula == nil) } } @@ -132,7 +134,7 @@ private extension FormulaPublishControllerTests { gitRemoteURL: String? = nil, directoryMap: [String: MockDirectory] = [:], projectFolder: MockDirectory? = nil - ) -> (sut: FormulaPublishController, store: MockPublishStore, project: MockDirectory) { + ) -> (sut: FormulaPublishController, delegate: MockDelegate, project: MockDirectory) { let projectFolder = projectFolder ?? MockDirectory(path: "/projects/tool") let picker = MockSwiftPicker( inputResult: .init(type: .ordered(inputResults)), @@ -141,10 +143,10 @@ private extension FormulaPublishControllerTests { ) let gitHandler = gitRemoteURL != nil ? MockGitHandler(remoteURL: gitRemoteURL!) : MockGitHandler() let fileSystem = MockFileSystem(directoryMap: directoryMap) - let store = MockPublishStore(taps: taps) - let sut = FormulaPublishController(picker: picker, gitHandler: gitHandler, fileSystem: fileSystem, store: store) + let delegate = MockDelegate(taps: taps) + let sut = FormulaPublishController(picker: picker, gitHandler: gitHandler, fileSystem: fileSystem, store: delegate, formulaFileService: delegate) - return (sut, store, projectFolder) + return (sut, delegate, projectFolder) } func makeFormula(name: String, tapPath: String, localProjectPath: String) -> HomebrewFormula { @@ -171,12 +173,13 @@ private extension FormulaPublishControllerTests { // MARK: - Mocks private extension FormulaPublishControllerTests { - final class MockPublishStore: PublishInfoStore { + final class MockDelegate: PublishInfoStore, FormulaFileService { private let taps: [HomebrewTap] - private(set) var updatedFormula: HomebrewFormula? - private(set) var savedFormula: HomebrewFormula? private(set) var savedTap: HomebrewTap? + private(set) var savedFormula: HomebrewFormula? + private(set) var updatedFormula: HomebrewFormula? + private(set) var formulaFileData: (formula: HomebrewFormula, tapFolder: any Directory, contents: String)? init(taps: [HomebrewTap]) { self.taps = taps @@ -194,5 +197,10 @@ private extension FormulaPublishControllerTests { savedFormula = formula savedTap = tap } + + func resolveFormulaFile(formula: HomebrewFormula, tapFolder: any Directory, contents: String) throws -> String { + formulaFileData = (formula, tapFolder, contents) + return "" + } } }