Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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
106 changes: 106 additions & 0 deletions Sources/NnexKit/Formula/HomebrewFormulaDecoder.swift
Original file line number Diff line number Diff line change
@@ -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: []
)
}
}
35 changes: 29 additions & 6 deletions Sources/NnexKit/Managers/HomebrewTapManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
}

Expand All @@ -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)
}
}
17 changes: 17 additions & 0 deletions Sources/NnexKit/Models/HomebrewTapImportResult.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
1 change: 1 addition & 0 deletions Sources/NnexKit/Shared/HomebrewTapService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
7 changes: 5 additions & 2 deletions Sources/NnexKit/Shared/HomebrewTapStoreAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
10 changes: 2 additions & 8 deletions Sources/nnex/Commands/Brew/CreateTap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Loading