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
82 changes: 82 additions & 0 deletions Sources/LucaCLI/Commands/InitCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// InitCommand.swift

import ArgumentParser
import Foundation
import LucaCore
import Noora

/// Creates a new spec file (Lucafile) in the current directory or the global config location.
struct InitCommand: AsyncParsableCommand {

enum InitCommandError: Error, LocalizedError, Equatable {
case abortedByUser

var errorDescription: String? {
switch self {
case .abortedByUser:
return "Aborted."
}
}
}

@OptionGroup var commonFlags: CommonFlags

static let configuration = CommandConfiguration(
commandName: "init",
abstract: "Create a new spec file to define your tools and skills.",
discussion: """
Interactively creates a Lucafile (or Toolfile / Skillfile) pre-populated with
commented examples. You choose whether the file is created in the current
project directory or in the global config location (~/.config/luca/).

Examples:
luca init
"""
)

func run() async throws {
let noora = Noora(terminal: Terminal(signalBehavior: .none))
let printer: Printing = Printer(noora: noora)
Header(printer: printer).printHeader()

let fileManager = FileManagerWrapper(fileManager: .default)
let specInitializer = SpecInitializer(fileManager: fileManager)

// 1. Ask where to create the file.
let locationLocal = "Current directory"
let locationGlobal = "Global (~/.config/luca/)"
let selectedLocation: String = noora.singleChoicePrompt(
title: "Location",
question: "Where do you want to create the spec file?",
options: [locationLocal, locationGlobal]
)
let location: SpecInitializer.Location = selectedLocation == locationGlobal ? .global : .local

// 2. Ask what to name the file.
let selectedName: String = noora.singleChoicePrompt(
title: "Filename",
question: "What should the spec file be named?",
options: Constants.specFiles
)

// 3. Try to create; if file exists ask to overwrite.
let targetURL: URL
do {
targetURL = try specInitializer.createSpec(named: selectedName, location: location)
} catch SpecInitializer.SpecInitializerError.fileAlreadyExists(let path) {
let overwrite = noora.yesOrNoChoicePrompt(
title: "File exists",
question: "\(path) already exists. Overwrite?",
defaultAnswer: false,
description: nil,
collapseOnSelection: true
)
guard overwrite else {
throw InitCommandError.abortedByUser
}
targetURL = try specInitializer.createSpec(named: selectedName, location: location, overwrite: true)
}

printer.printFormatted("\(.success("Created \(targetURL.path)"))")
}
}
3 changes: 3 additions & 0 deletions Sources/LucaCLI/Commands/LucaCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ struct LucaCommand: AsyncParsableCommand {
abstract: "A modern tool manager that helps you install and manage development tools.",
version: version,
groupedSubcommands: [
CommandGroup(name: "Setup", subcommands: [
InitCommand.self
]),
CommandGroup(name: "Execution", subcommands: [
RunCommand.self
]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public protocol FileManaging:
SkillSymLinkerFileManaging,
SkillUninstallerFileManaging,
SpecFinderFileManaging,
SpecInitializerFileManaging,
SymLinkFileManaging,
UnarchiverFileManaging {
var toolsFolder: URL { get }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SpecInitializerFileManaging.swift

import Foundation

/// File system interface for ``SpecInitializer``.
public protocol SpecInitializerFileManaging {
var homeDirectoryForCurrentUser: URL { get }
var currentDirectoryPath: String { get }
func fileExists(atPath path: String) -> Bool
func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws
func writeString(_ content: String, to url: URL) throws
}
81 changes: 81 additions & 0 deletions Sources/LucaCore/Core/SpecInitializer/SpecInitializer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// SpecInitializer.swift

import Foundation

/// Creates a new spec file on disk, pre-populated with a commented template.
public struct SpecInitializer {

/// Where the spec file should be written.
public enum Location {
/// The current working directory.
case local
/// The global config directory (`~/.config/luca/`).
case global
}

/// Errors thrown by ``SpecInitializer``.
public enum SpecInitializerError: Error, LocalizedError, Equatable {
case fileAlreadyExists(String)

public var errorDescription: String? {
switch self {
case .fileAlreadyExists(let path):
return "A spec file already exists at \(path)."
}
}
}

/// The default template written to every new spec file.
public static let template = """
---
repos:
# swift-testing: AvdLee/Swift-Testing-Agent-Skill

tools:
# - name: SwiftLint
# version: 0.61.0
# url: https://github.com/realm/SwiftLint/releases/download/0.61.0/portable_swiftlint.zip

skills:
# - name: swift-concurrency
# repository: AvdLee/Swift-Concurrency-Agent-Skill
# - name: swift-testing-expert
# repository: swift-testing
# version: 1.2.0
"""

private let fileManager: SpecInitializerFileManaging

public init(fileManager: SpecInitializerFileManaging) {
self.fileManager = fileManager
}

/// Creates a new spec file and returns its URL.
///
/// - Parameters:
/// - name: The filename (e.g. `"Lucafile"`).
/// - location: Where to write the file.
/// - overwrite: When `false` (default), throws ``SpecInitializerError/fileAlreadyExists(_:)`` if a file already exists at the target path.
/// - Returns: The URL of the created file.
@discardableResult
public func createSpec(named name: String, location: Location, overwrite: Bool = false) throws -> URL {
let directory: URL
switch location {
case .local:
directory = URL(fileURLWithPath: fileManager.currentDirectoryPath)
case .global:
directory = fileManager.homeDirectoryForCurrentUser.appending(components: ".config", "luca")
}

let targetURL = directory.appending(component: name)

if fileManager.fileExists(atPath: targetURL.path) && !overwrite {
throw SpecInitializerError.fileAlreadyExists(targetURL.path)
}

try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
try fileManager.writeString(Self.template, to: targetURL)

return targetURL
}
}
133 changes: 133 additions & 0 deletions Tests/Core/SpecInitializerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// SpecInitializerTests.swift

import Foundation
import Testing
@testable import LucaCore

struct SpecInitializerTests {

@Test
func test_createSpec_local_createsFileInCurrentDirectory() throws {
let fileManager = FileManagerWrapperMock()
let sut = SpecInitializer(fileManager: fileManager)

let url = try sut.createSpec(named: "Lucafile", location: .local)

#expect(url.lastPathComponent == "Lucafile")
#expect(url.deletingLastPathComponent().path == fileManager.currentDirectoryPath)
#expect(fileManager.fileExists(atPath: url.path))
}

@Test
func test_createSpec_global_createsFileInConfigDirectory() throws {
let fileManager = FileManagerWrapperMock()
let sut = SpecInitializer(fileManager: fileManager)

let url = try sut.createSpec(named: "Lucafile", location: .global)

let expectedDirectory = fileManager.homeDirectoryForCurrentUser.appending(components: ".config", "luca")
#expect(url == expectedDirectory.appending(component: "Lucafile"))
#expect(fileManager.fileExists(atPath: url.path))
}

@Test
func test_createSpec_global_createsParentDirectoryIfNeeded() throws {
let fileManager = FileManagerWrapperMock()
let sut = SpecInitializer(fileManager: fileManager)
let configDir = fileManager.homeDirectoryForCurrentUser.appending(components: ".config", "luca")
#expect(!fileManager.fileExists(atPath: configDir.path))

_ = try sut.createSpec(named: "Lucafile", location: .global)

#expect(fileManager.fileExists(atPath: configDir.path))
}

@Test
func test_createSpec_writesTemplate() throws {
let fileManager = FileManagerWrapperMock()
let sut = SpecInitializer(fileManager: fileManager)

let url = try sut.createSpec(named: "Lucafile", location: .local)

let content = try fileManager.readString(at: url)
#expect(content == SpecInitializer.template)
}

@Test
func test_createSpec_fileAlreadyExists_throwsError() throws {
let fileManager = FileManagerWrapperMock()
let sut = SpecInitializer(fileManager: fileManager)
let url = try sut.createSpec(named: "Lucafile", location: .local)

#expect(throws: SpecInitializer.SpecInitializerError.fileAlreadyExists(url.path)) {
try sut.createSpec(named: "Lucafile", location: .local)
}
}

@Test
func test_createSpec_overwrite_replacesFileContent() throws {
let fileManager = FileManagerWrapperMock()
let sut = SpecInitializer(fileManager: fileManager)
let url = try sut.createSpec(named: "Lucafile", location: .local)
try fileManager.writeString("existing content", to: url)

_ = try sut.createSpec(named: "Lucafile", location: .local, overwrite: true)

let content = try fileManager.readString(at: url)
#expect(content == SpecInitializer.template)
}

@Test
func test_createSpec_overwrite_doesNotThrowWhenFileExists() throws {
let fileManager = FileManagerWrapperMock()
let sut = SpecInitializer(fileManager: fileManager)
_ = try sut.createSpec(named: "Lucafile", location: .local)

#expect(throws: Never.self) {
try sut.createSpec(named: "Lucafile", location: .local, overwrite: true)
}
}

@Test(arguments: Constants.specFiles)
func test_createSpec_allFilenames(name: String) throws {
let fileManager = FileManagerWrapperMock()
let sut = SpecInitializer(fileManager: fileManager)

let url = try sut.createSpec(named: name, location: .local)

#expect(url.lastPathComponent == name)
#expect(fileManager.fileExists(atPath: url.path))
}

@Test
func test_createSpec_returnsCorrectURL() throws {
let fileManager = FileManagerWrapperMock()
let sut = SpecInitializer(fileManager: fileManager)

let url = try sut.createSpec(named: "Toolfile", location: .local)

let expectedURL = URL(fileURLWithPath: fileManager.currentDirectoryPath).appending(component: "Toolfile")
#expect(url == expectedURL)
}

@Test
func test_template_containsReposKey() {
#expect(SpecInitializer.template.contains("repos:"))
}

@Test
func test_template_containsToolsKey() {
#expect(SpecInitializer.template.contains("tools:"))
}

@Test
func test_template_containsSkillsKey() {
#expect(SpecInitializer.template.contains("skills:"))
}

@Test
func test_fileAlreadyExistsError_errorDescription() {
let error = SpecInitializer.SpecInitializerError.fileAlreadyExists("/path/to/Lucafile")
#expect(error.errorDescription == "A spec file already exists at /path/to/Lucafile.")
}
}