diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d52f986 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +Repository Guidelines +===================== + +## Project Structure & Module Organization +- Swift package rooted at `Package.swift`; production code lives under `Sources/` with the main CLI in `Sources/nnex` and shared libraries in `Sources/NnexKit` plus helpers in `Sources/NnexSharedTestHelpers`. +- Tests use Swift Testing and sit in `Tests/` (e.g., `Tests/nnexTests` for CLI-level coverage and `Tests/NnexKitTests` for kit-level logic). Keep new tests parallel to their source modules. +- Shared assets and resources reside in `Resources/`; docs in `docs/`. + +## Build, Test, and Development Commands +- `swift build` — compile the package. +- `swift test` — run the Swift Testing suites. Use `swift test --enable-code-coverage` when you need coverage locally. +- `swift package resolve` — ensure dependencies are fetched before building. +- Keep commands non-destructive and reproducible; favor `set -e` in scripts. + +## Coding Style & Naming Conventions +- Swift: 4-space indentation, `CamelCase` types, `lowerCamelCase` members. Keep parameter lists on one line when concise. +- File headers in Swift should attribute authorship to Nikolai Nobadi. +- Prefer modular, composable types; separate concerns between controllers (user interaction) and managers/services (business logic). +- Avoid embedding print/logging in lower-level managers; surface messages via controllers. + +## Testing Guidelines +- Framework: Swift Testing with `#expect`/`#require`. Use `NnexSharedTestHelpers` (e.g., `MockDirectory`, `MockGitHandler`) and `NnShellTesting.MockShell` for deterministic behavior. +- Name test files after the type under test; keep method names descriptive (e.g., `"Creates tap folder"` labels). +- Cover both success and failure paths; include warning/error propagation in assertions when applicable. +- Do not rely on real network or shell side effects; mock via provided test helpers. + +## Commit & Pull Request Guidelines +- Follow existing history: short, imperative commit messages (e.g., `add tap import warnings`, `refactor formula decoder`). +- PRs should state intent, summarize behavior changes, and note testing performed (or explicitly omitted per policy). Link related issues when available and call out any user-facing changes. + +## Security & Configuration Tips +- Git/GitHub interactions are mediated via `GitHandler`; ensure GitHub CLI availability is verified before creating repos. +- Scripts should be idempotent and avoid destructive defaults; when writing new scripts, emit colored INFO/SUCCESS/WARNING/ERROR messages and source shared utilities when present. + +## Resource Requests +- Ask before reading `~/.codex/guidelines/shared/shared-formatting-codex.md` when working on Swift code. +- Ask before reading `~/.codex/guidelines/testing/base_unit_testing_guidelines.md` when discussing or editing tests. +- Ask before reading `~/.codex/guidelines/testing/CLI_TESTING_GUIDE_CODEX.md` when discussing or editing CLI tests. +- Ask before reading `~/.codex/guidelines/cli/NnShellKit-Usage.md` when shell execution helpers are involved. +- Ask before reading `~/.codex/guidelines/cli/NnShellTesting-Usage.md` when working on shell-related tests. +- Ask before reading `~/.codex/guidelines/cli/SwiftPickerKit-usage.md` when touching SwiftPickerKit flows. +- Ask before reading `~/.codex/guidelines/cli/SwiftPickerTesting-usage.md` when testing SwiftPickerKit flows. + +## CLI Design +- Single-responsibility commands +- Clear, predictable argument handling +- Minimal logging to stdout/stderr +- Use `NnShellKit` for shell execution; prefer absolute program paths + +## CLI Testing +- Behavior-driven tests for command logic +- Use `makeSUT` pattern where applicable +- Test both success and error paths +- Verify output formatting +- Use `MockShell` from NnShellTesting for shell interactions diff --git a/Sources/NnexKit/Formula/HomebrewFormulaDecoder.swift b/Sources/NnexKit/Formula/HomebrewFormulaDecoder.swift new file mode 100644 index 0000000..f8ebf53 --- /dev/null +++ b/Sources/NnexKit/Formula/HomebrewFormulaDecoder.swift @@ -0,0 +1,106 @@ +// +// HomebrewFormulaDecoder.swift +// nnex +// +// Created by Nikolai Nobadi on 3/30/25. +// + +import Foundation + +struct HomebrewFormulaDecoder { + private let shell: any NnexShell + + init(shell: any NnexShell) { + self.shell = shell + } +} + + +// MARK: - Actions +extension HomebrewFormulaDecoder { + func decodeFormulas(in tapFolder: any Directory) throws -> ([HomebrewFormula], [String]) { + guard let formulaFolder = tapFolder.subdirectories.first(where: { $0.name == "Formula" }) else { + return ([], ["⚠️ Warning: No 'Formula' folder found in tap directory. Skipping formula import."]) + } + + var warnings: [String] = [] + let formulaFiles = try formulaFolder.findFiles(withExtension: "rb", recursive: false) + let formulas: [HomebrewFormula] = try formulaFiles.compactMap { filePath in + guard let brewFormula = try decodeBrewFormula(at: filePath, in: formulaFolder, warnings: &warnings) else { + return nil + } + + return makeHomebrewFormula(from: brewFormula) + } + + return (formulas, warnings) + } +} + + +// MARK: - Helpers +private extension HomebrewFormulaDecoder { + func decodeBrewFormula(at path: String, in formulaFolder: any Directory, warnings: inout [String]) throws -> DecodableFormulaTemplate? { + let output = (try? makeBrewOutput(filePath: path, warnings: &warnings)) ?? "" + + if !output.isEmpty, !output.contains("⚠️⚠️⚠️"), let data = output.data(using: .utf8) { + let decoder = JSONDecoder() + let rootObject = try decoder.decode([String: [DecodableFormulaTemplate]].self, from: data) + + return rootObject["formulae"]?.first + } + + let fileName = (path as NSString).lastPathComponent + let formulaContent = try formulaFolder.readFile(named: fileName) + let name = extractField(from: formulaContent, pattern: #"class (\w+) < Formula"#) ?? "Unknown" + let desc = extractField(from: formulaContent, pattern: #"desc\s+"([^"]+)""#) ?? "No description" + let homepage = extractField(from: formulaContent, pattern: #"homepage\s+"([^"]+)""#) ?? "No homepage" + let license = extractField(from: formulaContent, pattern: #"license\s+"([^"]+)""#) ?? "No license" + + return .init(name: name, desc: desc, homepage: homepage, license: license, versions: .init(stable: nil)) + } + + func makeBrewOutput(filePath: String, warnings: inout [String]) throws -> String { + let brewCheck = try shell.bash("which brew") + + if brewCheck.contains("not found") { + warnings.append("⚠️⚠️⚠️\nHomebrew has NOT been installed. You may want to install it soon...") + return "" + } + + return try shell.bash("brew info --json=v2 \(filePath)") + } + + func extractField(from text: String, pattern: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(text.startIndex..., in: text) + + if let match = regex.firstMatch(in: text, options: [], range: range), + let range = Range(match.range(at: 1), in: text) { + return String(text[range]) + } + + return nil + } + + func makeHomebrewFormula(from template: DecodableFormulaTemplate) -> HomebrewFormula { + let uploadType: HomebrewFormula.FormulaUploadType + + if let stable = template.versions.stable, stable.contains(".tar.gz") { + uploadType = .tarball + } else { + uploadType = .binary + } + + return .init( + name: template.name, + details: template.desc, + homepage: template.homepage, + license: template.license ?? "", + localProjectPath: "", + uploadType: uploadType, + testCommand: nil, + extraBuildArgs: [] + ) + } +} diff --git a/Sources/NnexKit/Managers/HomebrewTapManager.swift b/Sources/NnexKit/Managers/HomebrewTapManager.swift index 8595c77..7258f0f 100644 --- a/Sources/NnexKit/Managers/HomebrewTapManager.swift +++ b/Sources/NnexKit/Managers/HomebrewTapManager.swift @@ -5,18 +5,22 @@ // Created by Nikolai Nobadi on 12/12/25. // +import Foundation + public struct HomebrewTapManager { + private let shell: any NnexShell private let store: any HomebrewTapStore private let gitHandler: any GitHandler - public init(store: any HomebrewTapStore, gitHandler: any GitHandler) { + public init(shell: any NnexShell, store: any HomebrewTapStore, gitHandler: any GitHandler) { + self.shell = shell self.store = store self.gitHandler = gitHandler } } -// MARK: - CreateTap +// MARK: - HomebrewTapService extension HomebrewTapManager: HomebrewTapService { public func saveTapListFolderPath(path: String) { store.saveTapListFolderPath(path: path) @@ -28,7 +32,17 @@ extension HomebrewTapManager: HomebrewTapService { let tapFolder = try createTapFolder(named: name, in: parentFolder) let remotePath = try createRemoteRepository(folder: tapFolder, details: details, isPrivate: isPrivate) - try store.saveNewTap(.init(folder: tapFolder, remotePath: remotePath)) + try store.saveNewTap(.init(folder: tapFolder, remotePath: remotePath), formulas: []) + } + + public func importTap(from folder: any Directory) throws -> HomebrewTapImportResult { + try gitHandler.ghVerification() + + let (tap, warnings) = try makeTap(from: folder) + + try store.saveNewTap(tap, formulas: tap.formulas) + + return .init(tap: tap, warnings: warnings) } } @@ -49,19 +63,28 @@ private extension HomebrewTapManager { try gitHandler.gitInit(path: path) return try gitHandler.remoteRepoInit(tapName: folder.name, path: path, projectDetails: details, visibility: isPrivate ? .privateRepo : .publicRepo) } + + func makeTap(from folder: any Directory) throws -> (HomebrewTap, [String]) { + let decoder = HomebrewFormulaDecoder(shell: shell) + let tapName = folder.name.removingHomebrewPrefix + let remotePath = try gitHandler.getRemoteURL(path: folder.path) + let (formulas, warnings) = try decoder.decodeFormulas(in: folder) + + return (.init(name: tapName, localPath: folder.path, remotePath: remotePath, formulas: formulas), warnings) + } } // MARK: - Dependencies public protocol HomebrewTapStore { func saveTapListFolderPath(path: String) - func saveNewTap(_ tap: HomebrewTap) throws + func saveNewTap(_ tap: HomebrewTap, formulas: [HomebrewFormula]) throws } // MARK: - Extension Dependencies private extension HomebrewTap { - init(folder: any Directory, remotePath: String) { - self.init(name: folder.name, localPath: folder.path, remotePath: remotePath, formulas: []) + init(folder: any Directory, remotePath: String, formulas: [HomebrewFormula] = []) { + self.init(name: folder.name, localPath: folder.path, remotePath: remotePath, formulas: formulas) } } diff --git a/Sources/NnexKit/Models/HomebrewTapImportResult.swift b/Sources/NnexKit/Models/HomebrewTapImportResult.swift new file mode 100644 index 0000000..97becd9 --- /dev/null +++ b/Sources/NnexKit/Models/HomebrewTapImportResult.swift @@ -0,0 +1,17 @@ +// +// HomebrewTapImportResult.swift +// nnex +// +// Created by Nikolai Nobadi on 12/12/25. +// + + +public struct HomebrewTapImportResult { + public let tap: HomebrewTap + public let warnings: [String] + + public init(tap: HomebrewTap, warnings: [String]) { + self.tap = tap + self.warnings = warnings + } +} \ No newline at end of file diff --git a/Sources/NnexKit/Shared/HomebrewTapService.swift b/Sources/NnexKit/Shared/HomebrewTapService.swift index 61b60d0..766c0b6 100644 --- a/Sources/NnexKit/Shared/HomebrewTapService.swift +++ b/Sources/NnexKit/Shared/HomebrewTapService.swift @@ -8,4 +8,5 @@ public protocol HomebrewTapService { func saveTapListFolderPath(path: String) func createNewTap(named name: String, details: String, in parentFolder: any Directory, isPrivate: Bool) throws + func importTap(from folder: any Directory) throws -> HomebrewTapImportResult } diff --git a/Sources/NnexKit/Shared/HomebrewTapStoreAdapter.swift b/Sources/NnexKit/Shared/HomebrewTapStoreAdapter.swift index f0a263c..370931d 100644 --- a/Sources/NnexKit/Shared/HomebrewTapStoreAdapter.swift +++ b/Sources/NnexKit/Shared/HomebrewTapStoreAdapter.swift @@ -20,7 +20,10 @@ extension HomebrewTapStoreAdapter: HomebrewTapStore { context.saveTapListFolderPath(path: path) } - public func saveNewTap(_ tap: HomebrewTap) throws { - try context.saveNewTap(HomebrewTapMapper.toSwiftData(tap)) + public func saveNewTap(_ tap: HomebrewTap, formulas: [HomebrewFormula]) throws { + let swiftDataTap = HomebrewTapMapper.toSwiftData(tap) + let swiftDataFormulas = formulas.map(HomebrewFormulaMapper.toSwiftData) + + try context.saveNewTap(swiftDataTap, formulas: swiftDataFormulas) } } diff --git a/Sources/nnex/Commands/Brew/CreateTap.swift b/Sources/nnex/Commands/Brew/CreateTap.swift index 599ddb2..add1dba 100644 --- a/Sources/nnex/Commands/Brew/CreateTap.swift +++ b/Sources/nnex/Commands/Brew/CreateTap.swift @@ -22,16 +22,10 @@ extension Nnex.Brew { var isPrivate: Bool = false func run() throws { - let picker = Nnex.makePicker() - let gitHandler = Nnex.makeGitHandler() let context = try Nnex.makeContext() - let fileSystem = Nnex.makeFileSystem() - let folderBrowser = Nnex.makeFolderBrowser(picker: picker, fileSystem: fileSystem) - let store = HomebrewTapStoreAdapter(context: context) - let manager = HomebrewTapManager(store: store, gitHandler: gitHandler) - let controller = HomebrewTapController(picker: picker, fileSystem: fileSystem, service: manager, folderBrowser: folderBrowser) + let parentPath = context.loadTapListFolderPath() - try controller.createNewTap(name: name, details: details, parentPath: context.loadTapListFolderPath(), isPrivate: isPrivate) + try Nnex.makeHomebrewTapController(context: context).createNewTap(name: name, details: details, parentPath: parentPath, isPrivate: isPrivate) } } } diff --git a/Sources/nnex/Commands/Brew/ImportTap.swift b/Sources/nnex/Commands/Brew/ImportTap.swift index 71345b7..32b1ac0 100644 --- a/Sources/nnex/Commands/Brew/ImportTap.swift +++ b/Sources/nnex/Commands/Brew/ImportTap.swift @@ -17,109 +17,7 @@ extension Nnex.Brew { var path: String? func run() throws { - let picker = Nnex.makePicker() - let fileSystem = Nnex.makeFileSystem() - let folderBrowser = Nnex.makeFolderBrowser(picker: picker, fileSystem: fileSystem) - let context = try Nnex.makeContext() - let folder = try selectHomebrewFolder(path: path, folderBrowser: folderBrowser, fileSystem: fileSystem) - let tapName = folder.name.removingHomebrewPrefix - let remotePath = try Nnex.makeGitHandler().getRemoteURL(path: folder.path) - let formulaFiles = try findFormulaFiles(in: folder) - let tap = SwiftDataHomebrewTap(name: tapName, localPath: folder.path, remotePath: remotePath) - - var formulas: [SwiftDataHomebrewFormula] = [] - - for filePath in formulaFiles { - if let brewFormula = try decodeBrewFormula(at: filePath, fileSystem: fileSystem) { - formulas.append(.init(from: brewFormula)) - print("decoded \(brewFormula.name), added to tap.") - } - } - - try context.saveNewTap(tap, formulas: formulas) - } - } -} - - -// MARK: - Private Methods -private extension Nnex.Brew.ImportTap { - func selectHomebrewFolder(path: String?, folderBrowser: any DirectoryBrowser, fileSystem: any FileSystem) throws -> any Directory { - if let path { - return try fileSystem.directory(at: path) - } - - return try folderBrowser.browseForDirectory(prompt: "Select the Homebrew Tap folder you would like to import.") - } - - func findFormulaFiles(in folder: any Directory) throws -> [String] { - guard let formulaFolder = folder.subdirectories.first(where: { $0.name == "Formula" }) else { - print("⚠️ Warning: No 'Formula' folder found in tap directory. Skipping formula import.".red) - return [] - } - - return try formulaFolder.findFiles(withExtension: "rb", recursive: false) - } - /// Decodes a Homebrew formula from a file. - /// - Parameter path: The path to the file containing the formula. - /// - Returns: A DecodableFormulaTemplate instance if decoding is successful, or nil otherwise. - /// - Throws: An error if the decoding process fails. - func decodeBrewFormula(at path: String, fileSystem: any FileSystem) throws -> DecodableFormulaTemplate? { - let output: String - do { - output = try makeBrewOutput(filePath: path) - } catch { - output = "" + try Nnex.makeHomebrewTapController().importTap(path: path) } - - if output.isEmpty || output.contains("⚠️⚠️⚠️") { - let formulaContent = try fileSystem.readFile(at: path) - let name = extractField(from: formulaContent, pattern: #"class (\w+) < Formula"#) ?? "Unknown" - let desc = extractField(from: formulaContent, pattern: #"desc\s+"([^"]+)""#) ?? "No description" - let homepage = extractField(from: formulaContent, pattern: #"homepage\s+"([^"]+)""#) ?? "No homepage" - let license = extractField(from: formulaContent, pattern: #"license\s+"([^"]+)""#) ?? "No license" - - return .init(name: name, desc: desc, homepage: homepage, license: license, versions: .init(stable: nil)) - } else if let data = output.data(using: .utf8) { - let decoder = JSONDecoder() - let rootObject = try decoder.decode([String: [DecodableFormulaTemplate]].self, from: data) - - return rootObject["formulae"]?.first - } - - return nil - } - - /// Generates Homebrew formula output by running the brew info command. - /// - Parameter filePath: The path to the formula file. - /// - Returns: The output from the brew info command. - /// - Throws: An error if the command execution fails. - func makeBrewOutput(filePath: String) throws -> String { - let shell = Nnex.makeShell() - let brewCheck = try shell.bash("which brew") - - if brewCheck.contains("not found") { - print("⚠️⚠️⚠️\nHomebrew has NOT been installed. You may want to install it soon...".red.bold) - return "" - } - - return try shell.bash("brew info --json=v2 \(filePath)") - } - - /// Extracts a specific field from the given text using a regular expression pattern. - /// - Parameters: - /// - text: The text to search within. - /// - pattern: The regular expression pattern to use for extraction. - /// - Returns: The extracted field as a string, or nil if not found. - func extractField(from text: String, pattern: String) -> String? { - guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } - let range = NSRange(text.startIndex..., in: text) - - if let match = regex.firstMatch(in: text, options: [], range: range), - let range = Range(match.range(at: 1), in: text) { - return String(text[range]) - } - - return nil } } diff --git a/Sources/nnex/Controllers/HomebrewTapController.swift b/Sources/nnex/Controllers/HomebrewTapController.swift index b11386c..50f4bd5 100644 --- a/Sources/nnex/Controllers/HomebrewTapController.swift +++ b/Sources/nnex/Controllers/HomebrewTapController.swift @@ -31,6 +31,14 @@ extension HomebrewTapController { try service.createNewTap(named: name, details: details, in: parentFolder, isPrivate: isPrivate) } + + func importTap(path: String?) throws { + let tapFolder = try selectHomebrewFolder(path: path) + + let result = try service.importTap(from: tapFolder) + + result.warnings.forEach { print($0) } + } } @@ -73,4 +81,12 @@ private extension HomebrewTapController { return tapListFolder } + + func selectHomebrewFolder(path: String?) throws -> any Directory { + if let path { + return try fileSystem.directory(at: path) + } + + return try folderBrowser.browseForDirectory(prompt: "Select the Homebrew Tap folder you would like to import.") + } } diff --git a/Sources/nnex/Main/Nnex+ConvenienceFactoryMethods.swift b/Sources/nnex/Main/Nnex+ConvenienceFactoryMethods.swift new file mode 100644 index 0000000..755b918 --- /dev/null +++ b/Sources/nnex/Main/Nnex+ConvenienceFactoryMethods.swift @@ -0,0 +1,23 @@ +// +// Nnex+ConvenienceFactoryMethods.swift +// nnex +// +// Created by Nikolai Nobadi on 12/12/25. +// + +import NnexKit + +extension Nnex { + static func makeHomebrewTapController(context: NnexContext? = nil) throws -> HomebrewTapController { + let shell = makeShell() + let picker = makePicker() + let gitHandler = makeGitHandler() + let context = try context ?? makeContext() + let fileSystem = makeFileSystem() + let folderBrowser = makeFolderBrowser(picker: picker, fileSystem: fileSystem) + let store = HomebrewTapStoreAdapter(context: context) + let manager = HomebrewTapManager(shell: shell, store: store, gitHandler: gitHandler) + + return .init(picker: picker, fileSystem: fileSystem, service: manager, folderBrowser: folderBrowser) + } +} diff --git a/Sources/nnex/Main/nnex.swift b/Sources/nnex/Main/Nnex.swift similarity index 100% rename from Sources/nnex/Main/nnex.swift rename to Sources/nnex/Main/Nnex.swift diff --git a/Sources/nnex/Utilities/PublishInfoLoader.swift b/Sources/nnex/Utilities/PublishInfoLoader.swift index 5c2643e..0c9a4bf 100644 --- a/Sources/nnex/Utilities/PublishInfoLoader.swift +++ b/Sources/nnex/Utilities/PublishInfoLoader.swift @@ -43,6 +43,7 @@ extension PublishInfoLoader { let tap = try getTap(allTaps: allTaps) if var formula = tap.formulas.first(where: { $0.name.matches(projectFolder.name) }) { // Update the formula's localProjectPath if needed + // this is necessary if formulae have been imported and do not have the correct localProjectPath set if formula.localProjectPath.isEmpty || formula.localProjectPath != projectFolder.path { formula.localProjectPath = projectFolder.path try store.updateFormula(formula) diff --git a/Tests/NnexKitTests/HomebrewFormulaDecoderTests.swift b/Tests/NnexKitTests/HomebrewFormulaDecoderTests.swift new file mode 100644 index 0000000..2bd5f22 --- /dev/null +++ b/Tests/NnexKitTests/HomebrewFormulaDecoderTests.swift @@ -0,0 +1,98 @@ +// +// HomebrewFormulaDecoderTests.swift +// nnex +// +// Created by Nikolai Nobadi on 3/30/25. +// + +import Testing +import NnShellTesting +import NnexSharedTestHelpers +@testable import NnexKit + +struct HomebrewFormulaDecoderTests { + @Test("Returns warning when Formula folder is missing") + func missingFormulaFolder() throws { + let tapFolder = MockDirectory(path: "/taps/homebrew-myTap") + let (decoder, _) = makeSUT() + + let (formulas, warnings) = try decoder.decodeFormulas(in: tapFolder) + + #expect(formulas.isEmpty) + #expect(warnings.count == 1) + #expect(warnings.first?.contains("No 'Formula' folder") == true) + } + + @Test("Decodes formulas using brew JSON output") + func decodeUsingBrewOutput() throws { + let fileName = "mytool.rb" + let (tapFolder, _) = makeTapFolder(containedFiles: [fileName]) + let (decoder, shell) = makeSUT(shellResults: [ + "/opt/homebrew/bin/brew", // which brew + """ + {"formulae":[{"name":"mytool","desc":"A useful tool","homepage":"https://example.com","license":"MIT","versions":{"stable":"https://example.com/mytool.tar.gz"}}]} + """ + ]) + + let (formulas, warnings) = try decoder.decodeFormulas(in: tapFolder) + let formula = try #require(formulas.first) + + #expect(formulas.count == 1) + #expect(warnings.isEmpty) + #expect(formula.name == "mytool") + #expect(formula.details == "A useful tool") + #expect(formula.homepage == "https://example.com") + #expect(formula.license == "MIT") + #expect(formula.uploadType == .tarball) + #expect(shell.executedCommands.count == 2) + #expect(shell.executedCommands[1].contains("brew info --json=v2")) + #expect(shell.executedCommands[1].contains(fileName)) + } + + @Test("Falls back to parsing file contents when brew is unavailable") + func fallbackDecodingWhenBrewUnavailable() throws { + let fileName = "mytool.rb" + let (tapFolder, formulaFolder) = makeTapFolder(containedFiles: [fileName]) + formulaFolder.fileContents[fileName] = """ + class MyTool < Formula + desc "A fallback tool" + homepage "https://example.com/fallback" + license "Apache-2.0" + end + """ + let (decoder, shell) = makeSUT(shellResults: ["not found"]) // which brew + + let (formulas, warnings) = try decoder.decodeFormulas(in: tapFolder) + let formula = try #require(formulas.first) + + #expect(formulas.count == 1) + #expect(warnings.count == 1) + #expect(formula.name == "MyTool") + #expect(formula.details == "A fallback tool") + #expect(formula.homepage == "https://example.com/fallback") + #expect(formula.license == "Apache-2.0") + #expect(formula.uploadType == .binary) + #expect(shell.executedCommands.count == 1) + } +} + + +// MARK: - SUT +private extension HomebrewFormulaDecoderTests { + func makeSUT(shellResults: [String] = []) -> (sut: HomebrewFormulaDecoder, shell: MockShell) { + let shell = MockShell(results: shellResults) + let sut = HomebrewFormulaDecoder(shell: shell) + + return (sut, shell) + } +} + + +// MARK: - Test Helpers +private extension HomebrewFormulaDecoderTests { + func makeTapFolder(containedFiles: Set) -> (tap: MockDirectory, formula: MockDirectory) { + let formulaFolder = MockDirectory(path: "/taps/homebrew-myTap/Formula", containedFiles: containedFiles) + let tapFolder = MockDirectory(path: "/taps/homebrew-myTap", subdirectories: [formulaFolder]) + return (tapFolder, formulaFolder) + } +} diff --git a/Tests/NnexKitTests/HomebrewTapManagerTests.swift b/Tests/NnexKitTests/HomebrewTapManagerTests.swift index 98bc0c8..93505b3 100644 --- a/Tests/NnexKitTests/HomebrewTapManagerTests.swift +++ b/Tests/NnexKitTests/HomebrewTapManagerTests.swift @@ -7,6 +7,7 @@ import Testing import Foundation +import NnShellTesting import NnexSharedTestHelpers @testable import NnexKit @@ -17,6 +18,7 @@ final class HomebrewTapManagerTests { #expect(store.savedTap == nil) #expect(store.savedPath == nil) + #expect(store.savedFormulas.isEmpty) } @Test("Does not create tap when GitHub CLI is missing") @@ -29,6 +31,7 @@ final class HomebrewTapManagerTests { } #expect(store.savedTap == nil) + #expect(store.savedFormulas.isEmpty) #expect(gitHandler.gitInitPath == nil) #expect(gitHandler.remoteTapName == nil) #expect(parent.subdirectories.isEmpty) @@ -65,6 +68,7 @@ final class HomebrewTapManagerTests { #expect(savedTap.name == createdTapFolder.name) #expect(savedTap.localPath == createdTapFolder.path) #expect(savedTap.remotePath == remotePath) + #expect(store.savedFormulas.isEmpty) } @Test("Throws when git handler fails during tap creation") @@ -93,15 +97,74 @@ final class HomebrewTapManagerTests { #expect(store.savedTap == nil) #expect(gitHandler.remoteTapName == tapList.subdirectories.first?.name) } + + @Test("Imports existing tap and formulas") + func importTapSuccess() throws { + let formulaContent = """ + class MyTool < Formula + desc "A useful tool" + homepage "https://example.com" + license "MIT" + end + """ + let formulaFolder = MockDirectory(path: "/taps/homebrew-myTap/Formula", containedFiles: ["mytool.rb"]) + formulaFolder.fileContents["mytool.rb"] = formulaContent + let tapFolder = MockDirectory(path: "/taps/homebrew-myTap", subdirectories: [formulaFolder]) + let remotePath = "https://github.com/user/homebrew-myTap" + let (sut, store, _) = makeSUT(remoteURL: remotePath) + + let result = try sut.importTap(from: tapFolder) + + let savedTap = try #require(store.savedTap) + let savedFormula = try #require(store.savedFormulas.first) + + #expect(savedTap.name == "myTap") + #expect(savedTap.localPath == tapFolder.path) + #expect(savedTap.remotePath == remotePath) + #expect(store.savedFormulas.count == 1) + #expect(savedFormula.name == "MyTool") + #expect(savedFormula.details == "A useful tool") + #expect(savedFormula.homepage == "https://example.com") + #expect(savedFormula.license == "MIT") + #expect(result.warnings.isEmpty) + } + + @Test("Imports tap even when no formula folder exists") + func importTapWithoutFormulaFolder() throws { + let tapFolder = MockDirectory(path: "/taps/homebrew-myTap") + let (sut, store, _) = makeSUT(remoteURL: "remote") + + let result = try sut.importTap(from: tapFolder) + + let savedTap = try #require(store.savedTap) + + #expect(savedTap.name == "myTap") + #expect(store.savedFormulas.isEmpty) + #expect(result.warnings.count == 1) + } + + @Test("Does not import tap when GitHub CLI is missing") + func importTapMissingGHCLIFails() { + let tapFolder = MockDirectory(path: "/taps/homebrew-myTap") + let (sut, store, _) = makeSUT(ghIsInstalled: false) + + #expect(throws: NnexError.self) { + try sut.importTap(from: tapFolder) + } + + #expect(store.savedTap == nil) + #expect(store.savedFormulas.isEmpty) + } } // MARK: - SUT private extension HomebrewTapManagerTests { func makeSUT(storeThrows: Bool = false, gitThrows: Bool = false, ghIsInstalled: Bool = true, remoteURL: String = "remotePath") -> (sut: HomebrewTapManager, store: MockStore, gitHandler: MockGitHandler) { + let shell = MockShell() let store = MockStore(throwError: storeThrows) let gitHandler = MockGitHandler(remoteURL: remoteURL, ghIsInstalled: ghIsInstalled, throwError: gitThrows) - let sut = HomebrewTapManager(store: store, gitHandler: gitHandler) + let sut = HomebrewTapManager(shell: shell, store: store, gitHandler: gitHandler) return (sut, store, gitHandler) } @@ -115,6 +178,7 @@ private extension HomebrewTapManagerTests { private(set) var savedPath: String? private(set) var savedTap: HomebrewTap? + private(set) var savedFormulas: [HomebrewFormula] = [] init(throwError: Bool = false) { self.throwError = throwError @@ -124,10 +188,11 @@ private extension HomebrewTapManagerTests { savedPath = path } - func saveNewTap(_ tap: HomebrewTap) throws { + func saveNewTap(_ tap: HomebrewTap, formulas: [HomebrewFormula]) throws { if throwError { throw NSError(domain: "Test", code: 0) } savedTap = tap + savedFormulas = formulas } } } diff --git a/Tests/nnexTests/ControllerTests/HomebrewTapControllerTests.swift b/Tests/nnexTests/ControllerTests/HomebrewTapControllerTests.swift index b5711e0..4c6ea25 100644 --- a/Tests/nnexTests/ControllerTests/HomebrewTapControllerTests.swift +++ b/Tests/nnexTests/ControllerTests/HomebrewTapControllerTests.swift @@ -106,6 +106,31 @@ final class HomebrewTapControllerTests { #expect(service.savedTapData == nil) } + + @Test("Imports tap using provided path") + func importTapWithPath() throws { + let tapFolder = MockDirectory(path: "/taps/homebrew-myTap") + let fileSystem = MockFileSystem(directoryToLoad: tapFolder) + let (sut, service) = makeSUT(directoryToLoad: tapFolder, fileSystem: fileSystem) + + try sut.importTap(path: tapFolder.path) + + let imported = try #require(service.importedFolder) + + #expect(imported.path == tapFolder.path) + } + + @Test("Imports tap after browsing for directory") + func importTapWithBrowse() throws { + let browsedDirectory = MockDirectory(path: "/taps/homebrew-myTap") + let (sut, service) = makeSUT(browsedDirectory: browsedDirectory) + + try sut.importTap(path: nil) + + let imported = try #require(service.importedFolder) + + #expect(imported.path == browsedDirectory.path) + } } @@ -129,6 +154,7 @@ private extension HomebrewTapControllerTests { private let throwError: Bool private(set) var savedPath: String? + private(set) var importedFolder: (any Directory)? private(set) var savedTapData: (name: String, details: String, parent: any Directory, isPrivate: Bool)? init(throwError: Bool) { @@ -144,5 +170,13 @@ private extension HomebrewTapControllerTests { savedTapData = (name, details, parentFolder, isPrivate) } + + func importTap(from folder: any Directory) throws -> HomebrewTapImportResult { + if throwError { throw NSError(domain: "Test", code: 0) } + + importedFolder = folder + + return .init(tap: .init(name: folder.name, localPath: folder.path, remotePath: "", formulas: []), warnings: []) + } } } diff --git a/Tests/nnexTests/ImportTapTests/BrewImportTapTests.swift b/Tests/nnexTests/ImportTapTests/BrewImportTapTests.swift index 9bc217e..7b15619 100644 --- a/Tests/nnexTests/ImportTapTests/BrewImportTapTests.swift +++ b/Tests/nnexTests/ImportTapTests/BrewImportTapTests.swift @@ -12,7 +12,7 @@ import NnexSharedTestHelpers @testable import nnex @preconcurrency import Files -@MainActor // needs to be MainActor to ensure proper interactions with SwiftData +@MainActor final class BrewImportTapTests { private let tapName = "testTap" private let tapFolder: Folder @@ -50,9 +50,9 @@ extension BrewImportTapTests { #expect(newTap.formulas.isEmpty) } - @Test("Imports empty tap from existing folder from selection", .disabled()) // TODO: - + @Test("Imports empty tap from existing folder from selection") func importsEmptyTapFromSelection() throws { - let testFactory = MockContextFactory() + let testFactory = MockContextFactory(browsedDirectory: MockDirectory(path: tapFolder.path)) let context = try testFactory.makeContext() try runCommand(testFactory) @@ -63,8 +63,6 @@ extension BrewImportTapTests { #expect(newTap.formulas.isEmpty) } - - @Test("Imports tap from existing folder and decodes existing formula when path is passed as arg") func importTapWithFormula() throws { let name = "testFormula" diff --git a/Tests/nnexTests/Shared/MockContextFactory.swift b/Tests/nnexTests/Shared/MockContextFactory.swift index 895d546..7eb3fa3 100644 --- a/Tests/nnexTests/Shared/MockContextFactory.swift +++ b/Tests/nnexTests/Shared/MockContextFactory.swift @@ -21,6 +21,7 @@ final class MockContextFactory { private let inputResponses: [String] private let permissionResponses: [Bool] private let gitHandler: MockGitHandler + private let browsedDirectory: MockDirectory? private var shell: MockShell? private var picker: MockSwiftPicker? private var context: NnexContext? @@ -35,7 +36,8 @@ final class MockContextFactory { permissionResponses: [Bool] = [], gitHandler: MockGitHandler = .init(), shell: MockShell? = nil, - fileSystem: MockFileSystem? = nil + fileSystem: MockFileSystem? = nil, + browsedDirectory: MockDirectory? = nil ) { self.shell = shell self.runResults = runResults @@ -46,6 +48,7 @@ final class MockContextFactory { self.selectedItemIndex = selectedItemIndex self.permissionResponses = permissionResponses self.selectedItemIndices = selectedItemIndices + self.browsedDirectory = browsedDirectory } } @@ -108,7 +111,10 @@ extension MockContextFactory: ContextFactory { } func makeFolderBrowser(picker: any NnexPicker, fileSystem: any FileSystem) -> any DirectoryBrowser { - // TODO: - change to mock when possible + if let browsedDirectory { + return MockDirectoryBrowser(filePathToReturn: nil, directoryToReturn: browsedDirectory) + } + return DefaultDirectoryBrowser(picker: picker, fileSystem: fileSystem, homeDirectoryURL: FileManager.default.homeDirectoryForCurrentUser) } }