cat swift/traccia-cli-argument-parser-file-system.md
Traccia CLI: ArgumentParser, file system e automazioni
Costruire strumenti da terminale in Swift con ArgumentParser, accesso al file system, output leggibile e testabilità.
Una CLI è spesso il modo più sottovalutato per usare Swift.
Puoi creare:
- tool interni;
- generatori;
- script compilati;
- automazioni;
- comandi per CI;
- utility di conversione dati;
- client API da terminale.
Swift è forte per CLI perché combina tipi sicuri, async/await, SwiftPM e binari distribuibili.
Package eseguibile
Con SwiftPM puoi creare un executable target.
let package = Package(
name: "TaskTool",
platforms: [.macOS(.v14)],
products: [
.executable(name: "tasktool", targets: ["TaskTool"])
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0")
],
targets: [
.executableTarget(
name: "TaskTool",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
"TaskCore"
]
),
.target(name: "TaskCore"),
.testTarget(name: "TaskCoreTests", dependencies: ["TaskCore"])
]
)
La CLI dipende dal core, non il contrario.
Comando base
import ArgumentParser
@main
struct TaskTool: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "task",
abstract: "Gestisce attività da terminale.",
subcommands: [
Add.self,
List.self,
Complete.self
]
)
}
Ogni sotto-comando rappresenta una intenzione.
Add command
struct Add: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Aggiunge una nuova attività."
)
@Argument(help: "Titolo dell'attività.")
var title: String
@Option(help: "Priorita: low, normal, high.")
var priority: TaskPriority = .normal
mutating func run() async throws {
let environment = try CLIEnvironment.live()
let draft = try TaskDraft(title: title, priority: priority)
let task = try await environment.createTask.execute(draft: draft)
environment.output.write("Creata: \\(task.title)")
}
}
La CLI legge argomenti, costruisce input di dominio e chiama un caso d’uso.
Output come dipendenza
Non usare print ovunque se vuoi testare.
protocol OutputWriter: Sendable {
func write(_ text: String)
}
struct ConsoleOutputWriter: OutputWriter {
func write(_ text: String) {
print(text)
}
}
Spy per test:
actor SpyOutputWriter: OutputWriter {
private(set) var lines: [String] = []
func write(_ text: String) {
lines.append(text)
}
}
File system
Una CLI spesso legge e scrive file.
Evita di usare FileManager.default direttamente nei casi d’uso.
protocol TaskStore: Sendable {
func load() async throws -> [TaskItem]
func save(_ tasks: [TaskItem]) async throws
}
Implementazione JSON:
struct JSONFileTaskStore: TaskStore {
let fileURL: URL
let encoder: JSONEncoder
let decoder: JSONDecoder
func load() async throws -> [TaskItem] {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return []
}
let data = try Data(contentsOf: fileURL)
return try decoder.decode([TaskItem].self, from: data)
}
func save(_ tasks: [TaskItem]) async throws {
let data = try encoder.encode(tasks)
try data.write(to: fileURL, options: [.atomic])
}
}
Per app più grandi puoi incapsulare anche FileManager.
Percorsi configurabili
Non scrivere sempre nella cartella corrente.
struct CLIConfiguration {
let dataFileURL: URL
static func live() throws -> CLIConfiguration {
let home = FileManager.default.homeDirectoryForCurrentUser
let directory = home.appending(path: ".tasktool")
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
return CLIConfiguration(
dataFileURL: directory.appending(path: "tasks.json")
)
}
}
Una CLI professionale deve sapere dove mette i dati.
Exit code ed errori
ArgumentParser gestisce molti errori di parsing. Per errori applicativi, traduci bene:
do {
let draft = try TaskDraft(title: title, priority: priority)
_ = try await createTask.execute(draft: draft)
} catch TaskError.emptyTitle {
throw ValidationError("Il titolo non può essere vuoto.")
}
L’utente da terminale ha bisogno di messaggi brevi e azionabili.
Subcommand complete
struct Complete: AsyncParsableCommand {
@Argument(help: "ID dell'attività da completare.")
var id: String
mutating func run() async throws {
let environment = try CLIEnvironment.live()
try await environment.completeTask.execute(id: TaskItem.ID(id))
environment.output.write("Completata: \\(id)")
}
}
La CLI non sa come il task viene salvato. Lo decide l’environment.
Testare la CLI
Testa prima il core.
Poi testa parsing e output dove ha senso.
@Test
func addScriveOutputDiSuccesso() async throws {
let output = SpyOutputWriter()
let command = AddCommand(
createTask: CreateTaskUseCase(repository: InMemoryTaskRepository()),
output: output
)
try await command.run(title: "Scrivere corso", priority: .high)
let lines = await output.lines
#expect(lines.contains("Creata: Scrivere corso"))
}
Una CLI testabile non deve scrivere per forza su disco reale.
Automazioni
Una CLI Swift può entrare in:
- GitHub Actions;
- script di release;
- generazione codice;
- migrazioni dati;
- controllo documentazione;
- tool interni di team.
Per questo sono importanti:
- output stabile;
- opzione
--jsonquando serve; - exit code corretti;
- nessuna interazione obbligatoria nei comandi CI;
- log leggibili.
Checklist
Una traccia CLI sana:
- executable target separato dal core;
ArgumentParserper parsing type-safe;- subcommand per intenzioni;
- file system incapsulato;
- output iniettabile;
- errori tradotti in messaggi chiari;
- core testato senza terminale;
- modalità JSON per automazioni;
- config e path espliciti.
Esercizio
Crea tasktool con tre comandi:
task add "Scrivere modulo 9" --priority high
task list
task complete t-1
Vincoli:
- il dominio sta in
TaskCore; - la CLI sta in
TaskTool; - i dati vengono salvati in JSON;
printè isolato dietroOutputWriter;- i test del core non leggono il file system reale.