Sviluppare applicazioni CLI in Java

Sviluppare applicazioni CLI in Java

Java non è il primo linguaggio che viene in mente quando si parla di strumenti da riga di comando. La fama di piattaforma lenta ad avviarsi e prolissa lo ha relegato per anni al ruolo di linguaggio per server e applicazioni enterprise. Eppure Maven, Gradle, il compilatore stesso e strumenti come jbang o jreleaser sono CLI scritte in Java, e le versioni moderne del linguaggio, con record, pattern matching e la compilazione nativa di GraalVM, hanno cambiato radicalmente il quadro. In questo articolo costruiremo tasks, un gestore di attività con persistenza su file JSON, usando picocli, la libreria di riferimento per le CLI su JVM, e Jackson per la serializzazione.

Le convenzioni da rispettare

Le regole sono le stesse che valgono per qualsiasi strumento da riga di comando:

  • i risultati vanno su standard output, errori e diagnostica su standard error;
  • il codice di uscita vale 0 in caso di successo, 1 per gli errori di esecuzione e 2 per gli errori di utilizzo; picocli adotta esattamente questa convenzione con le costanti CommandLine.ExitCode.OK, SOFTWARE e USAGE;
  • nessuno stack trace per gli errori previsti;
  • colori solo su terminale e mai se NO_COLOR è impostata;
  • un formato JSON per l'uso da script.
tasks add "Rinnovare il certificato TLS" --priority high
tasks list --all --format json
tasks done 3
tasks rm 3

Struttura del progetto

Usiamo Maven e Java 25, l'ultima versione con supporto a lungo termine. Il progetto ha un unico modulo:

tasks/
├── pom.xml
└── src/
    ├── main/java/com/example/tasks/
    │   ├── Priority.java
    │   ├── Task.java
    │   ├── Store.java
    │   ├── StoreException.java
    │   └── TasksCommand.java
    └── test/java/com/example/tasks/
        └── TasksCommandTest.java
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>tasks</artifactId>
  <version>1.0.0</version>

  <properties>
    <maven.compiler.release>25</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <picocli.version>4.7.7</picocli.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>info.picocli</groupId>
      <artifactId>picocli</artifactId>
      <version>${picocli.version}</version>
    </dependency>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.20.0</version>
    </dependency>
    <dependency>
      <groupId>com.fasterxml.jackson.datatype</groupId>
      <artifactId>jackson-datatype-jsr310</artifactId>
      <version>2.20.0</version>
    </dependency>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.13.4</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <resources>
      <!-- Il filtraggio sostituisce ${project.version} nel file di versione. -->
      <resource>
        <directory>src/main/resources</directory>
        <filtering>true</filtering>
      </resource>
    </resources>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.0</version>
        <configuration>
          <annotationProcessorPaths>
            <path>
              <groupId>info.picocli</groupId>
              <artifactId>picocli-codegen</artifactId>
              <version>${picocli.version}</version>
            </path>
          </annotationProcessorPaths>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.3</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-shade-plugin</artifactId>
        <version>3.6.0</version>
        <executions>
          <execution>
            <phase>package</phase>
            <goals><goal>shade</goal></goals>
            <configuration>
              <transformers>
                <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                  <mainClass>com.example.tasks.TasksCommand</mainClass>
                </transformer>
              </transformers>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>

Il processore di annotazioni picocli-codegen verifica in fase di compilazione la correttezza delle annotazioni e genera i file di configurazione necessari a GraalVM per la compilazione nativa, di cui parleremo alla fine. Il plugin maven-shade-plugin produce invece un unico JAR eseguibile che include tutte le dipendenze.

Il modello dei dati

I record di Java sono ideali per rappresentare un'attività: immutabili, con equals, hashCode e toString generati, e supportati nativamente da Jackson. Per modificare un'attività creiamo una nuova istanza con un metodo dedicato, sul modello dei metodi with diffusi nelle API immutabili.

package com.example.tasks;

import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Arrays;
import java.util.Locale;
import java.util.stream.Collectors;

public enum Priority {
    LOW, NORMAL, HIGH;

    // Jackson serializza la priorità in minuscolo, come nel resto della serie.
    @JsonValue
    public String label() {
        return name().toLowerCase(Locale.ROOT);
    }

    public static String allowedValues() {
        return Arrays.stream(values()).map(Priority::label).collect(Collectors.joining(", "));
    }
}
package com.example.tasks;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;

public record Task(
        int id,
        String title,
        Priority priority,
        boolean done,
        @JsonProperty("created_at") Instant createdAt) {

    public Task markDone() {
        return new Task(id, title, priority, true, createdAt);
    }
}

picocli è in grado di convertire automaticamente una stringa in un valore di un'enumerazione, ma per impostazione predefinita confronta i nomi rispettando maiuscole e minuscole: --priority high fallirebbe perché la costante si chiama HIGH. Lo risolveremo con l'opzione caseInsensitiveEnumValuesAllowed.

Il livello di persistenza

La classe Store legge e scrive il file JSON. Riceve l'ambiente come mappa invece di leggere direttamente System.getenv: Java non permette di modificare le variabili d'ambiente del processo in esecuzione, quindi senza questo accorgimento sarebbe impossibile controllare il percorso del file nei test.

package com.example.tasks;

/** Errore di esecuzione legato ai dati (codice di uscita 1). */
public class StoreException extends RuntimeException {
    public StoreException(String message) {
        super(message);
    }

    public StoreException(String message, Throwable cause) {
        super(message, cause);
    }
}
package com.example.tasks;

import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

public final class Store {

    static final ObjectMapper JSON = new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .enable(SerializationFeature.INDENT_OUTPUT);

    private final Path path;

    public Store(Path path) {
        this.path = path;
    }

    public Path path() {
        return path;
    }

    /** Restituisce il percorso del file dati rispettando le convenzioni del sistema. */
    public static Path defaultPath(Map<String, String> env) {
        String override = env.get("TASKS_FILE");
        if (override != null && !override.isBlank()) {
            return Path.of(override);
        }
        String os = System.getProperty("os.name", "").toLowerCase();
        Path home = Path.of(System.getProperty("user.home"));
        Path base;
        if (os.startsWith("windows")) {
            String appData = env.get("APPDATA");
            base = appData != null ? Path.of(appData) : home.resolve("AppData").resolve("Roaming");
        } else if (os.startsWith("mac")) {
            base = home.resolve("Library").resolve("Application Support");
        } else {
            String xdg = env.get("XDG_DATA_HOME");
            base = xdg != null && !xdg.isBlank() ? Path.of(xdg) : home.resolve(".local").resolve("share");
        }
        return base.resolve("tasks").resolve("tasks.json");
    }

Il modulo JavaTimeModule, fornito dalla dipendenza jackson-datatype-jsr310, serve a Jackson per gestire Instant. Disattivando WRITE_DATES_AS_TIMESTAMPS le date vengono scritte nel formato ISO 8601, leggibile e portabile.

La lettura tratta il file assente come una lista vuota. La scrittura è atomica: si scrive su un file temporaneo nella stessa directory, si forza lo scaricamento sul disco con FileChannel.force e si sposta il file sopra l'originale con ATOMIC_MOVE.

    public List<Task> load() {
        try {
            byte[] raw = Files.readAllBytes(path);
            return new ArrayList<>(JSON.readValue(raw, new TypeReference<List<Task>>() {}));
        } catch (NoSuchFileException e) {
            return new ArrayList<>();
        } catch (JacksonException e) {
            throw new StoreException("il file " + path + " non è valido: " + e.getOriginalMessage(), e);
        } catch (IOException e) {
            throw new StoreException("impossibile leggere " + path + ": " + e.getMessage(), e);
        }
    }

    public void save(List<Task> tasks) {
        Path dir = path.toAbsolutePath().getParent();
        Path tmp = null;
        try {
            Files.createDirectories(dir);
            tmp = Files.createTempFile(dir, ".tasks-", ".json");
            byte[] data = (JSON.writeValueAsString(tasks) + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8);
            try (FileChannel channel = FileChannel.open(tmp, StandardOpenOption.WRITE)) {
                ByteBuffer buffer = ByteBuffer.wrap(data);
                while (buffer.hasRemaining()) {
                    channel.write(buffer);
                }
                // Forza la scrittura su disco prima dello spostamento.
                channel.force(true);
            }
            Files.move(tmp, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
        } catch (IOException e) {
            throw new StoreException("impossibile scrivere " + path + ": " + e.getMessage(), e);
        } finally {
            if (tmp != null) {
                try {
                    Files.deleteIfExists(tmp);
                } catch (IOException ignored) {
                    // Il file temporaneo è già stato spostato o non è eliminabile: nulla da fare.
                }
            }
        }
    }

    public static int nextId(List<Task> tasks) {
        return tasks.stream().mapToInt(Task::id).max().orElse(0) + 1;
    }

    public static int indexOf(List<Task> tasks, int id) {
        for (int i = 0; i < tasks.size(); i++) {
            if (tasks.get(i).id() == id) {
                return i;
            }
        }
        throw new StoreException("attività non trovata: " + id);
    }
}

I comandi con picocli

picocli usa le annotazioni per descrivere la CLI: @Command definisce un comando con i suoi sottocomandi, @Option e @Parameters descrivono opzioni e argomenti posizionali, che vengono assegnati direttamente ai campi della classe. I sottocomandi possono essere metodi annotati della classe principale o classi separate; usiamo i metodi, che rendono il codice compatto e condividono naturalmente lo stato del comando principale, cioè lo store e i flussi di output.

package com.example.tasks;

import java.io.PrintWriter;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.ParameterException;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.Spec;

@Command(
        name = "tasks",
        description = "Un semplice gestore di attività da riga di comando.",
        mixinStandardHelpOptions = true,
        versionProvider = TasksCommand.VersionProvider.class)
public class TasksCommand implements Callable<Integer> {

    @Spec
    CommandSpec spec;

    private final Store store;
    private final Map<String, String> env;

    public TasksCommand(Store store, Map<String, String> env) {
        this.store = store;
        this.env = env;
    }

    @Override
    public Integer call() {
        // "tasks" senza sottocomando è un errore di utilizzo.
        throw new ParameterException(spec.commandLine(), "specificare un comando");
    }

    private PrintWriter out() {
        return spec.commandLine().getOut();
    }

L'annotazione @Spec fa iniettare da picocli il modello del comando, attraverso il quale otteniamo i flussi di output configurati. Non scriviamo mai direttamente su System.out: usando getOut() e getErr() i test potranno sostituire i flussi con dei StringWriter. L'opzione mixinStandardHelpOptions aggiunge automaticamente -h, --help, -V e --version.

add

    @Command(name = "add", description = "Aggiunge un'attività.", mixinStandardHelpOptions = true)
    int add(
            @Option(names = {"-p", "--priority"}, defaultValue = "normal",
                    description = "Priorità: ${COMPLETION-CANDIDATES} (predefinita: ${DEFAULT-VALUE}).")
            Priority priority,
            @Parameters(arity = "1..*", paramLabel = "<titolo>", description = "Titolo dell'attività.")
            List<String> titleWords) {

        String title = String.join(" ", titleWords).strip();
        if (title.isEmpty()) {
            throw new ParameterException(spec.commandLine(), "il titolo non può essere vuoto");
        }
        List<Task> tasks = store.load();
        Task task = new Task(Store.nextId(tasks), title, priority, false, Instant.now());
        tasks.add(task);
        store.save(tasks);
        out().printf("Aggiunta l'attività %d: %s%n", task.id(), task.title());
        return CommandLine.ExitCode.OK;
    }

Gli argomenti del metodo vengono popolati da picocli: la priorità viene convertita automaticamente nell'enumerazione, mentre arity = "1..*" sul titolo richiede almeno una parola e permette di scrivere tasks add Comprare il latte senza virgolette. Le variabili ${COMPLETION-CANDIDATES} e ${DEFAULT-VALUE} nella descrizione vengono sostituite nell'aiuto con i valori ammessi e quello predefinito.

list

Il formato di output è modellato con un'enumerazione interna, così che picocli rifiuti da solo valori diversi da table e json con un errore di utilizzo.

    enum Format { TABLE, JSON }

    @Command(name = "list", aliases = "ls", description = "Elenca le attività.", mixinStandardHelpOptions = true)
    int list(
            @Option(names = {"-a", "--all"}, description = "Include le attività completate.")
            boolean all,
            @Option(names = {"-f", "--format"}, defaultValue = "table",
                    description = "Formato di output: ${COMPLETION-CANDIDATES}.")
            Format format) throws Exception {

        List<Task> tasks = store.load().stream().filter(t -> all || !t.done()).toList();
        if (format == Format.JSON) {
            out().println(Store.JSON.writeValueAsString(tasks));
            return CommandLine.ExitCode.OK;
        }
        printTable(tasks);
        return CommandLine.ExitCode.OK;
    }

    private void printTable(List<Task> tasks) {
        if (tasks.isEmpty()) {
            out().println("Nessuna attività.");
            return;
        }
        int idWidth = Math.max(2, tasks.stream().mapToInt(t -> String.valueOf(t.id()).length()).max().orElse(0));
        String row = "%-" + idWidth + "s  %-6s  %s  %s%n";
        out().printf(row, "ID", "STATO", "PRIORITÀ", "TITOLO");
        for (Task t : tasks) {
            // La cella viene riempita prima di essere colorata, così l'allineamento resta corretto.
            String priority = paint(t.priority(), String.format("%-8s", t.priority().label()));
            out().printf(row, t.id(), t.done() ? "fatta" : "aperta", priority, t.title());
        }
    }

done e rm

Per gli ID dichiariamo il parametro come int: se l'utente scrive tasks done abc, picocli produce da solo un messaggio di errore chiaro e il codice di uscita 2. Resta da verificare che l'ID sia positivo.

    @Command(name = "done", description = "Segna un'attività come completata.", mixinStandardHelpOptions = true)
    int done(@Parameters(paramLabel = "<id>", description = "ID dell'attività.") int id) {
        requirePositive(id);
        List<Task> tasks = store.load();
        int index = Store.indexOf(tasks, id);
        tasks.set(index, tasks.get(index).markDone());
        store.save(tasks);
        out().printf("Attività %d completata.%n", id);
        return CommandLine.ExitCode.OK;
    }

    @Command(name = "rm", aliases = "remove", description = "Elimina un'attività.", mixinStandardHelpOptions = true)
    int remove(@Parameters(paramLabel = "<id>", description = "ID dell'attività.") int id) {
        requirePositive(id);
        List<Task> tasks = store.load();
        tasks.remove(Store.indexOf(tasks, id));
        store.save(tasks);
        out().printf("Attività %d eliminata.%n", id);
        return CommandLine.ExitCode.OK;
    }

    private void requirePositive(int id) {
        if (id <= 0) {
            throw new ParameterException(spec.commandLine(), "l'ID deve essere positivo: " + id);
        }
    }

Colori e terminale

picocli colora l'aiuto in modo autonomo quando rileva un terminale, ma per i colori del nostro output decidiamo noi. La classe Console espone dalla versione 22 il metodo isTerminal, che indica se standard input e standard output sono collegati a un terminale interattivo; combinandolo con il controllo di NO_COLOR otteniamo il comportamento corretto.

    private static final Map<Priority, String> COLORS = Map.of(
            Priority.HIGH, "\u001B[31m",
            Priority.NORMAL, "\u001B[33m",
            Priority.LOW, "\u001B[90m");
    private static final String RESET = "\u001B[0m";

    private boolean colorEnabled() {
        String noColor = env.get("NO_COLOR");
        if (noColor != null && !noColor.isEmpty()) {
            return false;
        }
        java.io.Console console = System.console();
        return console != null && console.isTerminal();
    }

    private String paint(Priority priority, String text) {
        return colorEnabled() ? COLORS.get(priority) + text + RESET : text;
    }

Errori, codici di uscita e punto di ingresso

Il metodo CommandLine.execute è il cuore dell'esecuzione: analizza gli argomenti, invoca il comando e restituisce il codice di uscita. Per gli errori di parsing, come opzioni sconosciute, argomenti mancanti o valori non convertibili, stampa il messaggio e l'uso del comando su standard error e restituisce 2. Per le eccezioni sollevate durante l'esecuzione, invece, stampa l'intero stack trace e restituisce 1. Il codice di uscita è corretto, ma lo stack trace non lo è: per un errore previsto come un ID inesistente l'utente deve vedere un messaggio, non trenta righe di stack. Installiamo quindi un gestore personalizzato delle eccezioni di esecuzione.

    /** Costruisce la CommandLine configurata: usata sia da main sia dai test. */
    public static CommandLine commandLine(Store store, Map<String, String> env) {
        CommandLine cmd = new CommandLine(new TasksCommand(store, env));
        cmd.setCaseInsensitiveEnumValuesAllowed(true);
        cmd.setExecutionExceptionHandler((exception, commandLine, parseResult) -> {
            PrintWriter err = commandLine.getErr();
            if (exception instanceof StoreException) {
                err.println("tasks: " + exception.getMessage());
            } else {
                // Errore imprevisto: è un bug, lo stack trace solo su richiesta.
                err.println("tasks: errore interno: " + exception);
                if (env.containsKey("DEBUG")) {
                    exception.printStackTrace(err);
                }
            }
            return CommandLine.ExitCode.SOFTWARE;
        });
        return cmd;
    }

    public static void main(String[] args) {
        Map<String, String> env = System.getenv();
        int code = commandLine(new Store(Store.defaultPath(env)), env).execute(args);
        System.exit(code);
    }

    static final class VersionProvider implements CommandLine.IVersionProvider {
        @Override
        public String[] getVersion() throws Exception {
            var props = new java.util.Properties();
            try (var in = TasksCommand.class.getResourceAsStream("/version.properties")) {
                if (in != null) {
                    props.load(in);
                }
            }
            return new String[] {"tasks " + props.getProperty("version", "dev")};
        }
    }
}

La versione arriva da un file di risorse, src/main/resources/version.properties, che contiene la sola riga version=${project.version}: grazie al filtraggio delle risorse configurato nel pom.xml, Maven sostituisce il segnaposto con la versione del progetto in fase di build, mantenendo un'unica fonte di verità.

Anche le ParameterException sollevate dal nostro codice, come quella per l'ID non positivo o per il comando mancante, vengono trattate da picocli come errori di parsing: messaggio, uso del comando e codice 2. È per questo che le abbiamo usate al posto di eccezioni generiche.

Per i segnali, la JVM esegue gli shutdown hook alla ricezione di SIGINT e SIGTERM: è lì che va collocata un'eventuale pulizia per operazioni lunghe. Nel nostro caso non serve, perché la scrittura atomica garantisce che un'interruzione lasci il file dati sempre integro.

Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    // Eseguito anche su Ctrl+C: chiudere connessioni e rimuovere file temporanei.
    importer.cancel();
}));

Test

Poiché commandLine accetta store e ambiente, i test possono eseguire l'applicazione esattamente come farebbe un utente, catturando output ed errori con dei StringWriter. L'annotazione @TempDir di JUnit fornisce una directory temporanea diversa per ogni test.

package com.example.tasks;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import com.fasterxml.jackson.databind.JsonNode;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class TasksCommandTest {

    @TempDir
    Path dir;

    private Store store;

    record Result(int code, String out, String err) {}

    @BeforeEach
    void setUp() {
        store = new Store(dir.resolve("tasks.json"));
    }

    private Result run(String... args) {
        StringWriter out = new StringWriter();
        StringWriter err = new StringWriter();
        var cmd = TasksCommand.commandLine(store, Map.of("NO_COLOR", "1"));
        cmd.setOut(new PrintWriter(out, true));
        cmd.setErr(new PrintWriter(err, true));
        int code = cmd.execute(args);
        return new Result(code, out.toString(), err.toString());
    }

    @Test
    void addsAndListsTasksAsJson() throws Exception {
        assertEquals(0, run("add", "Scrivere", "i", "test", "--priority", "high").code());

        Result result = run("list", "--format", "json");
        assertEquals(0, result.code());
        JsonNode tasks = Store.JSON.readTree(result.out());
        assertEquals(1, tasks.size());
        assertEquals("Scrivere i test", tasks.get(0).get("title").asText());
        assertEquals("high", tasks.get(0).get("priority").asText());
    }

    @Test
    void hidesCompletedTasksByDefault() throws Exception {
        run("add", "Prima");
        run("add", "Seconda");
        run("done", "1");

        assertEquals(1, Store.JSON.readTree(run("list", "-f", "json").out()).size());
        assertEquals(2, Store.JSON.readTree(run("list", "-a", "-f", "json").out()).size());
    }

    @ParameterizedTest
    @CsvSource({
        "'',                         2",
        "fly,                        2",
        "add,                        2",
        "add x --priority urgent,    2",
        "list --colour,              2",
        "done abc,                   2",
        "done 0,                     2",
        "done 42,                    1",
        "list --help,                0",
    })
    void exitCodes(String commandLine, int expected) {
        String[] args = commandLine.isBlank() ? new String[0] : commandLine.trim().split("\\s+");
        assertEquals(expected, run(args).code());
    }

    @Test
    void errorsGoToStderrWithoutStackTrace() {
        Result result = run("rm", "7");
        assertEquals(1, result.code());
        assertEquals("", result.out());
        assertTrue(result.err().startsWith("tasks: attività non trovata"));
        assertFalse(result.err().contains("at com.example"));
    }

    @Test
    void corruptedFileIsReported() throws Exception {
        Files.writeString(store.path(), "{non json");
        Result result = run("list");
        assertEquals(1, result.code());
        assertTrue(result.err().contains("non è valido"));
    }
}

I test parametrizzati richiedono l'artefatto junit-jupiter-params, già incluso nell'aggregatore junit-jupiter dichiarato nel pom.xml.

mvn test

Distribuzione

Il comando mvn package produce, grazie al plugin Shade, un JAR che contiene tutte le dipendenze. Per renderlo comodo da usare basta un piccolo script di avvio:

mvn -q package
sudo install -m 0644 target/tasks-1.0.0.jar /usr/local/lib/tasks.jar

sudo tee /usr/local/bin/tasks > /dev/null <<'EOF'
#!/bin/sh
exec java -XX:TieredStopAtLevel=1 -Xshare:auto -jar /usr/local/lib/tasks.jar "$@"
EOF
sudo chmod +x /usr/local/bin/tasks

L'opzione -XX:TieredStopAtLevel=1 limita la compilazione JIT al primo livello: per un processo che vive poche centinaia di millisecondi riduce sensibilmente il tempo di avvio, a scapito di prestazioni di picco che comunque non verrebbero mai raggiunte.

Compilazione nativa con GraalVM

Il vero salto di qualità arriva con native-image di GraalVM, che compila l'applicazione in anticipo in un eseguibile nativo: avvio in pochi millisecondi, nessuna JVM richiesta sulla macchina di destinazione e consumo di memoria ridotto. La compilazione nativa richiede di conoscere in anticipo le classi usate tramite riflessione, ed è qui che torna utile picocli-codegen, che ha già generato la configurazione per tutte le classi annotate. Jackson usa a sua volta la riflessione per i record, quindi conviene eseguire la suite di test con l'agente di tracciamento per raccogliere la configurazione mancante.

# Raccoglie la configurazione di riflessione eseguendo l'applicazione reale.
java -agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-image \
  -jar target/tasks-1.0.0.jar list --format json

native-image -jar target/tasks-1.0.0.jar -o tasks --no-fallback
./tasks --version

Come per gli altri linguaggi compilati, l'eseguibile nativo è specifico della piattaforma: va prodotto separatamente per Linux, macOS e Windows, tipicamente con una matrice di job nella pipeline di integrazione continua. Strumenti come JReleaser automatizzano la pubblicazione degli artefatti, dei pacchetti e delle formule Homebrew.

Conclusione

picocli ci ha permesso di descrivere l'intera interfaccia in modo dichiarativo: conversione dei tipi, validazione dei valori ammessi, aiuto generato e codici di uscita conformi alla convenzione arrivano senza codice aggiuntivo. Il nostro lavoro si è concentrato sulle decisioni che una libreria non può prendere: nascondere lo stack trace per gli errori previsti, iniettare ambiente e flussi per rendere l'applicazione testabile, scrivere i dati in modo atomico e colorare l'output solo quando ha senso. Con la compilazione nativa di GraalVM, infine, cade anche l'ultimo argomento contro Java per gli strumenti da riga di comando: il tempo di avvio.