cat swift/traccia-cli-argument-parser-file-system.md

Lezione 68 65 min

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 --json quando serve;
  • exit code corretti;
  • nessuna interazione obbligatoria nei comandi CI;
  • log leggibili.

Checklist

Una traccia CLI sana:

  • executable target separato dal core;
  • ArgumentParser per 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:

  1. il dominio sta in TaskCore;
  2. la CLI sta in TaskTool;
  3. i dati vengono salvati in JSON;
  4. print è isolato dietro OutputWriter;
  5. i test del core non leggono il file system reale.