Sviluppare applicazioni CLI in C++

Sviluppare applicazioni CLI in C++

Gli strumenti da riga di comando sono l'ambiente naturale del C++: compilatori, debugger, sistemi di build e una buona parte delle utility che usiamo ogni giorno sono scritti in C o C++. Eppure, quando si tratta di scrivere una propria CLI, molti sviluppatori C++ si fermano al ciclo manuale su argv, perché la libreria standard non offre nulla per il parsing degli argomenti né per il formato JSON. In questo articolo costruiremo tasks, un gestore di attività con persistenza su file JSON, usando C++20, CMake, la libreria CLI11 per il parsing e nlohmann/json per la serializzazione, entrambe header-only e scaricate automaticamente da CMake.

Le convenzioni da rispettare

Le regole sono le stesse di ogni strumento Unix ben educato:

  • 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;
  • nessuna eccezione non gestita deve arrivare a std::terminate: l'utente deve ricevere un messaggio, non un abort;
  • 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

Separiamo la logica in una libreria statica, collegata sia all'eseguibile sia ai test. Il file main.cpp resta minimale, come in tutti gli altri linguaggi della serie.

tasks/
├── CMakeLists.txt
├── include/tasks/
│   ├── cli.hpp
│   └── store.hpp
├── src/
│   ├── cli.cpp
│   ├── main.cpp
│   └── store.cpp
└── tests/
    └── cli_test.cpp
cmake_minimum_required(VERSION 3.24)
project(tasks VERSION 1.0.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

include(FetchContent)
FetchContent_Declare(cli11
  URL https://github.com/CLIUtils/CLI11/archive/refs/tags/v2.5.0.tar.gz
  DOWNLOAD_EXTRACT_TIMESTAMP ON)
FetchContent_Declare(json
  URL https://github.com/nlohmann/json/releases/download/v3.12.0/json.tar.xz
  DOWNLOAD_EXTRACT_TIMESTAMP ON)
FetchContent_MakeAvailable(cli11 json)

add_library(tasks_core STATIC src/store.cpp src/cli.cpp)
target_include_directories(tasks_core PUBLIC include)
target_link_libraries(tasks_core PUBLIC CLI11::CLI11 nlohmann_json::nlohmann_json)
target_compile_definitions(tasks_core PRIVATE TASKS_VERSION="${PROJECT_VERSION}")
if(MSVC)
  target_compile_options(tasks_core PRIVATE /W4 /utf-8)
else()
  target_compile_options(tasks_core PRIVATE -Wall -Wextra -Wpedantic)
endif()

add_executable(tasks src/main.cpp)
target_link_libraries(tasks PRIVATE tasks_core)
install(TARGETS tasks RUNTIME DESTINATION bin)

option(TASKS_BUILD_TESTS "Compila i test" ON)
if(TASKS_BUILD_TESTS)
  FetchContent_Declare(catch2
    URL https://github.com/catchorg/Catch2/archive/refs/tags/v3.8.1.tar.gz
    DOWNLOAD_EXTRACT_TIMESTAMP ON)
  FetchContent_MakeAvailable(catch2)
  add_executable(tasks_tests tests/cli_test.cpp)
  target_link_libraries(tasks_tests PRIVATE tasks_core Catch2::Catch2WithMain)
  enable_testing()
  add_test(NAME tasks_tests COMMAND tasks_tests)
endif()

La versione del progetto viene passata al codice come macro TASKS_VERSION: il CMakeLists.txt resta l'unica fonte di verità. L'opzione /utf-8 per MSVC garantisce che le stringhe con caratteri accentati vengano interpretate correttamente anche su Windows.

Il livello di persistenza

L'interfaccia dello store dichiara il modello dei dati, un'eccezione dedicata agli errori di esecuzione e le operazioni di lettura e scrittura. La macro NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE genera le funzioni di conversione da e verso JSON, evitando di scriverle a mano.

#pragma once

#include <filesystem>
#include <functional>
#include <optional>
#include <stdexcept>
#include <string>
#include <vector>

#include <nlohmann/json.hpp>

namespace tasks {

// Errore di esecuzione legato ai dati (codice di uscita 1).
class StoreError : public std::runtime_error {
public:
    using std::runtime_error::runtime_error;
};

struct Task {
    int id = 0;
    std::string title;
    std::string priority = "normal";
    bool done = false;
    std::string created_at;
};

NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Task, id, title, priority, done, created_at)

// Funzione che restituisce il valore di una variabile d'ambiente, se presente.
using EnvLookup = std::function<std::optional<std::string>(const std::string&)>;

class Store {
public:
    explicit Store(std::filesystem::path path) : path_(std::move(path)) {}

    static std::filesystem::path default_path(const EnvLookup& env);

    [[nodiscard]] std::vector<Task> load() const;
    void save(const std::vector<Task>& tasks) const;

    [[nodiscard]] const std::filesystem::path& path() const { return path_; }

    static int next_id(const std::vector<Task>& tasks);
    static std::size_t index_of(const std::vector<Task>& tasks, int id);

private:
    std::filesystem::path path_;
};

}  // namespace tasks

Anche qui l'ambiente viene passato come funzione: in produzione leggerà le variabili reali con std::getenv, nei test restituirà valori controllati.

L'implementazione del percorso predefinito segue le convenzioni di ciascun sistema operativo:

#include "tasks/store.hpp"

#include <algorithm>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <random>
#include <sstream>

#ifdef _WIN32
#include <io.h>
#else
#include <unistd.h>
#endif

namespace fs = std::filesystem;

namespace tasks {

fs::path Store::default_path(const EnvLookup& env) {
    if (auto value = env("TASKS_FILE"); value && !value->empty()) {
        return fs::path(*value);
    }
#ifdef _WIN32
    fs::path base = env("APPDATA").value_or(env("USERPROFILE").value_or(".") + "\\AppData\\Roaming");
#elif defined(__APPLE__)
    fs::path base = fs::path(env("HOME").value_or(".")) / "Library" / "Application Support";
#else
    auto xdg = env("XDG_DATA_HOME");
    fs::path base = (xdg && !xdg->empty()) ? fs::path(*xdg) : fs::path(env("HOME").value_or(".")) / ".local" / "share";
#endif
    return base / "tasks" / "tasks.json";
}

La lettura tratta il file assente come una lista vuota e converte ogni errore di parsing della libreria JSON in un StoreError con un messaggio chiaro.

std::vector<Task> Store::load() const {
    std::ifstream in(path_, std::ios::binary);
    if (!in) {
        if (!fs::exists(path_)) {
            return {};
        }
        throw StoreError("impossibile leggere " + path_.string());
    }
    try {
        return nlohmann::json::parse(in).get<std::vector<Task>>();
    } catch (const nlohmann::json::exception& e) {
        throw StoreError("il file " + path_.string() + " non è valido: " + e.what());
    }
}

Scrittura atomica

La libreria standard non offre un modo per forzare lo scaricamento di un file sul disco: std::ofstream::flush svuota solo il buffer della libreria verso il sistema operativo, che può a sua volta tenere i dati in memoria. Usiamo quindi std::FILE, da cui otteniamo il descrittore per chiamare fsync su POSIX e _commit su Windows. Lo schema è lo stesso di tutta la serie: file temporaneo nella stessa directory, scaricamento forzato e rinomina sopra l'originale con std::filesystem::rename, che sostituisce la destinazione esistente su tutte le piattaforme.

namespace {

// Genera un nome casuale per il file temporaneo nella directory di destinazione.
fs::path temp_path_for(const fs::path& target) {
    std::random_device device;
    std::uniform_int_distribution<unsigned> dist(0, 0xFFFFFF);
    std::ostringstream name;
    name << ".tasks-" << std::hex << dist(device) << ".json";
    return target.parent_path() / name.str();
}

bool sync_file(std::FILE* file) {
#ifdef _WIN32
    return _commit(_fileno(file)) == 0;
#else
    return ::fsync(fileno(file)) == 0;
#endif
}

}  // namespace

void Store::save(const std::vector<Task>& tasks) const {
    const fs::path target = fs::absolute(path_);
    std::error_code ec;
    fs::create_directories(target.parent_path(), ec);
    if (ec) {
        throw StoreError("impossibile creare " + target.parent_path().string() + ": " + ec.message());
    }

    const std::string data = nlohmann::json(tasks).dump(2) + "\n";
    const fs::path tmp = temp_path_for(target);

    std::FILE* file = std::fopen(tmp.string().c_str(), "wb");
    if (file == nullptr) {
        throw StoreError("impossibile creare " + tmp.string() + ": " + std::strerror(errno));
    }
    const bool written = std::fwrite(data.data(), 1, data.size(), file) == data.size()
        && std::fflush(file) == 0
        && sync_file(file);
    std::fclose(file);
    if (!written) {
        fs::remove(tmp, ec);
        throw StoreError("impossibile scrivere " + tmp.string());
    }

    fs::rename(tmp, target, ec);
    if (ec) {
        fs::remove(tmp, ec);
        throw StoreError("impossibile sostituire " + target.string() + ": " + ec.message());
    }
}

int Store::next_id(const std::vector<Task>& tasks) {
    int max_id = 0;
    for (const auto& task : tasks) {
        max_id = std::max(max_id, task.id);
    }
    return max_id + 1;
}

std::size_t Store::index_of(const std::vector<Task>& tasks, int id) {
    auto it = std::find_if(tasks.begin(), tasks.end(), [id](const Task& t) { return t.id == id; });
    if (it == tasks.end()) {
        throw StoreError("attività non trovata: " + std::to_string(id));
    }
    return static_cast<std::size_t>(it - tasks.begin());
}

}  // namespace tasks

Usiamo ovunque gli overload di std::filesystem che accettano un std::error_code: le versioni senza quel parametro sollevano filesystem_error, e preferiamo tradurre noi ogni errore in un StoreError con un messaggio pensato per l'utente.

Il parsing degli argomenti con CLI11

CLI11 è una libreria header-only che offre sottocomandi, opzioni brevi e lunghe, validatori, aiuto generato e conversione automatica dei tipi. La sua interfaccia è dichiarativa: si collegano le opzioni direttamente a variabili, che vengono popolate durante il parsing.

L'interfaccia del modulo CLI espone una sola funzione, che riceve gli argomenti, i flussi di output, lo store e il flag dei colori, e restituisce il codice di uscita. È il punto di ingresso che useranno sia main sia i test.

#pragma once

#include <ostream>
#include <string>
#include <vector>

#include "tasks/store.hpp"

namespace tasks {

inline constexpr int exit_ok = 0;
inline constexpr int exit_failure = 1;
inline constexpr int exit_usage = 2;

struct Io {
    std::ostream& out;
    std::ostream& err;
    bool color = false;
};

// Esegue la CLI; args non include il nome del programma.
int run(const std::vector<std::string>& args, Io io, const Store& store);

}  // namespace tasks

La definizione dell'applicazione dichiara i quattro sottocomandi con le relative opzioni. require_subcommand(1) rende obbligatorio esattamente un sottocomando, il validatore CLI::IsMember limita i valori ammessi per priorità e formato, e CLI::Range rifiuta gli ID non positivi o non numerici.

#include "tasks/cli.hpp"

#include <algorithm>
#include <chrono>
#include <format>
#include <limits>
#include <map>

#include <CLI/CLI.hpp>

#ifndef TASKS_VERSION
#define TASKS_VERSION "dev"
#endif

namespace tasks {

namespace {

const std::vector<std::string> priorities{"low", "normal", "high"};

std::string now_utc() {
    const auto now = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
    return std::format("{:%FT%TZ}", now);
}

std::string join(const std::vector<std::string>& words) {
    std::string result;
    for (const auto& word : words) {
        if (!result.empty()) {
            result += ' ';
        }
        result += word;
    }
    return result;
}

// Riempie una cella fino alla larghezza indicata, in caratteri UTF-8 visibili.
std::string pad(const std::string& text, std::size_t width) {
    const auto visible = static_cast<std::size_t>(
        std::count_if(text.begin(), text.end(), [](unsigned char c) { return (c & 0xC0) != 0x80; }));
    return visible >= width ? text : text + std::string(width - visible, ' ');
}

}  // namespace

La funzione pad merita un commento: std::string::size conta i byte, non i caratteri, e in UTF-8 una lettera accentata come la À di PRIORITÀ occupa due byte. Contando solo i byte che non sono di continuazione, cioè quelli che non iniziano con i bit 10, otteniamo il numero di caratteri e quindi un allineamento corretto delle colonne.

I comandi

Invece di eseguire la logica nelle callback di CLI11, dopo il parsing controlliamo quale sottocomando è stato selezionato con parsed. In questo modo le eccezioni dello store non si mescolano con quelle del parsing, e il flusso del programma resta lineare e facile da seguire.

int run(const std::vector<std::string>& args, Io io, const Store& store) {
    CLI::App app{"Un semplice gestore di attività da riga di comando.", "tasks"};
    app.set_version_flag("-V,--version", std::string("tasks ") + TASKS_VERSION);
    app.require_subcommand(1);

    std::vector<std::string> title_words;
    std::string priority = "normal";
    auto* add = app.add_subcommand("add", "Aggiunge un'attività");
    add->add_option("title", title_words, "Titolo dell'attività")->required();
    add->add_option("-p,--priority", priority, "Priorità")
        ->check(CLI::IsMember(priorities))
        ->capture_default_str();

    bool show_all = false;
    std::string format = "table";
    auto* list = app.add_subcommand("list", "Elenca le attività")->alias("ls");
    list->add_flag("-a,--all", show_all, "Include le attività completate");
    list->add_option("-f,--format", format, "Formato di output")
        ->check(CLI::IsMember({"table", "json"}))
        ->capture_default_str();

    int id = 0;
    auto* done = app.add_subcommand("done", "Segna un'attività come completata");
    done->add_option("id", id, "ID dell'attività")->required()->check(CLI::Range(1, std::numeric_limits<int>::max()));

    auto* remove = app.add_subcommand("rm", "Elimina un'attività")->alias("remove");
    remove->add_option("id", id, "ID dell'attività")->required()->check(CLI::Range(1, std::numeric_limits<int>::max()));

CLI11 espone un metodo parse che accetta un std::vector<std::string>, ma con una particolarità poco intuitiva: si aspetta gli argomenti in ordine inverso, perché li consuma dalla fine. Per evitare sorprese ricostruiamo un array in stile argv e usiamo l'overload classico. Gli errori di parsing derivano tutti da CLI::ParseError; le richieste di aiuto e di versione vengono segnalate con eccezioni della stessa famiglia, ma con codice zero.

    std::vector<const char*> argv{"tasks"};
    for (const auto& arg : args) {
        argv.push_back(arg.c_str());
    }

    try {
        app.parse(static_cast<int>(argv.size()), argv.data());
    } catch (const CLI::ParseError& e) {
        // app.exit stampa l'aiuto su out o il messaggio di errore su err.
        const int code = app.exit(e, io.out, io.err);
        if (code == static_cast<int>(CLI::ExitCodes::Success)) {
            return exit_ok;
        }
        return exit_usage;
    }

Il metodo exit di CLI11 restituirebbe codici propri, come 106 per un argomento obbligatorio mancante o 109 per un valore non valido. Sono informativi per chi sviluppa, ma non rispettano la convenzione che vuole tutti gli errori di utilizzo con codice 2: per questo usiamo exit solo per stampare il messaggio e decidiamo noi il codice.

Superato il parsing, eseguiamo il sottocomando selezionato. Tutta la logica è racchiusa in un blocco che intercetta StoreError, trasformandolo in un messaggio con codice 1.

    try {
        if (add->parsed()) {
            auto tasks = store.load();
            Task task{Store::next_id(tasks), join(title_words), priority, false, now_utc()};
            tasks.push_back(task);
            store.save(tasks);
            io.out << "Aggiunta l'attività " << task.id << ": " << task.title << '\n';
        } else if (list->parsed()) {
            auto tasks = store.load();
            std::erase_if(tasks, [&](const Task& t) { return t.done && !show_all; });
            if (format == "json") {
                io.out << nlohmann::json(tasks).dump(2) << '\n';
            } else if (tasks.empty()) {
                io.out << "Nessuna attività.\n";
            } else {
                static const std::map<std::string, std::string> colors{
                    {"high", "\033[31m"}, {"normal", "\033[33m"}, {"low", "\033[90m"}};
                io.out << pad("ID", 4) << pad("STATO", 8) << pad("PRIORITÀ", 10) << "TITOLO\n";
                for (const auto& t : tasks) {
                    // La cella viene riempita prima di essere colorata.
                    std::string cell = pad(t.priority, 8);
                    if (io.color) {
                        cell = colors.at(t.priority) + cell + "\033[0m";
                    }
                    io.out << pad(std::to_string(t.id), 4) << pad(t.done ? "fatta" : "aperta", 8)
                           << cell << "  " << t.title << '\n';
                }
            }
        } else if (done->parsed()) {
            auto tasks = store.load();
            tasks[Store::index_of(tasks, id)].done = true;
            store.save(tasks);
            io.out << "Attività " << id << " completata.\n";
        } else if (remove->parsed()) {
            auto tasks = store.load();
            tasks.erase(tasks.begin() + static_cast<std::ptrdiff_t>(Store::index_of(tasks, id)));
            store.save(tasks);
            io.out << "Attività " << id << " eliminata.\n";
        }
    } catch (const StoreError& e) {
        io.err << "tasks: " << e.what() << '\n';
        return exit_failure;
    }
    return exit_ok;
}

}  // namespace tasks

Il titolo viene raccolto in un std::vector<std::string>: CLI11 assegna a un argomento posizionale vettoriale tutte le parole rimanenti, così tasks add Comprare il latte funziona senza virgolette. std::erase_if, introdotto in C++20, rimuove in una sola chiamata gli elementi che soddisfano il predicato, sostituendo il vecchio idioma erase-remove.

Il punto di ingresso, i colori e i segnali

Il file main.cpp compone gli oggetti con i flussi reali e decide se abilitare i colori. Per sapere se lo standard output è un terminale usiamo isatty su POSIX e _isatty su Windows.

#include <cstdlib>
#include <exception>
#include <iostream>

#include "tasks/cli.hpp"

#ifdef _WIN32
#include <io.h>
#define TASKS_ISATTY(fd) _isatty(fd)
#else
#include <unistd.h>
#define TASKS_ISATTY(fd) ::isatty(fd)
#endif

namespace {

std::optional<std::string> env_lookup(const std::string& name) {
    if (const char* value = std::getenv(name.c_str())) {
        return std::string(value);
    }
    return std::nullopt;
}

bool color_enabled() {
    const auto no_color = env_lookup("NO_COLOR");
    return TASKS_ISATTY(1) != 0 && !(no_color && !no_color->empty());
}

}  // namespace

int main(int argc, char** argv) {
    try {
        std::ios::sync_with_stdio(false);
        const tasks::Store store(tasks::Store::default_path(env_lookup));
        const std::vector<std::string> args(argv + 1, argv + argc);
        return tasks::run(args, {std::cout, std::cerr, color_enabled()}, store);
    } catch (const std::exception& e) {
        // Ultima rete di sicurezza: nessuna eccezione deve arrivare a std::terminate.
        std::cerr << "tasks: errore interno: " << e.what() << '\n';
        return tasks::exit_failure;
    }
}

Il blocco try in main è essenziale. In C++ un'eccezione non catturata provoca la chiamata a std::terminate, che di norma termina il processo con abort: l'utente vede un messaggio criptico, il codice di uscita è 134 e su alcuni sistemi viene generato un core dump. Un errore imprevisto deve invece produrre un messaggio leggibile e un codice 1.

Ritornare da main invece di chiamare std::exit garantisce inoltre che i distruttori degli oggetti locali vengano eseguiti e che i flussi vengano svuotati.

Per le operazioni lunghe, la gestione dei segnali in C++ richiede attenzione: dentro un gestore di segnale si possono fare pochissime cose in modo sicuro. Lo schema corretto è impostare una variabile di tipo volatile std::sig_atomic_t e controllarla periodicamente nel ciclo di lavoro:

#include <csignal>

namespace {
volatile std::sig_atomic_t interrupted = 0;

extern "C" void on_signal(int) {
    // Nel gestore si può solo scrivere su una variabile sig_atomic_t.
    interrupted = 1;
}
}  // namespace

int import_tasks(const std::vector<std::string>& sources, tasks::Io io) {
    std::signal(SIGINT, on_signal);
    std::signal(SIGTERM, on_signal);
    for (const auto& source : sources) {
        if (interrupted) {
            io.err << "tasks: importazione interrotta\n";
            // 130 = 128 + SIGINT, la convenzione delle shell per i processi interrotti
            return 130;
        }
        import_one(source);
    }
    return tasks::exit_ok;
}

Test

Con flussi e store iniettati, i test usano std::ostringstream per catturare l'output ed eseguono la CLI completa in memoria. Usiamo Catch2 v3, che CMake scarica insieme alle altre dipendenze.

#include <filesystem>
#include <random>
#include <sstream>

#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <nlohmann/json.hpp>

#include "tasks/cli.hpp"

namespace fs = std::filesystem;

namespace {

struct Result {
    int code;
    std::string out;
    std::string err;
};

// Directory temporanea eliminata automaticamente alla fine del test.
struct TempDir {
    fs::path path;
    TempDir() : path(fs::temp_directory_path() / ("tasks-test-" + std::to_string(std::random_device{}()))) {
        fs::create_directories(path);
    }
    ~TempDir() {
        std::error_code ec;
        fs::remove_all(path, ec);
    }
};

Result run(const tasks::Store& store, std::vector<std::string> args) {
    std::ostringstream out;
    std::ostringstream err;
    const int code = tasks::run(args, {out, err, false}, store);
    return {code, out.str(), err.str()};
}

}  // namespace

TEST_CASE("aggiunge ed elenca le attività in JSON") {
    TempDir dir;
    const tasks::Store store(dir.path / "tasks.json");

    REQUIRE(run(store, {"add", "Scrivere", "i", "test", "--priority", "high"}).code == tasks::exit_ok);

    const auto result = run(store, {"list", "--format", "json"});
    REQUIRE(result.code == tasks::exit_ok);
    const auto json = nlohmann::json::parse(result.out);
    REQUIRE(json.size() == 1);
    CHECK(json[0]["title"] == "Scrivere i test");
    CHECK(json[0]["priority"] == "high");
}

TEST_CASE("nasconde le attività completate se non si usa --all") {
    TempDir dir;
    const tasks::Store store(dir.path / "tasks.json");
    run(store, {"add", "Prima"});
    run(store, {"add", "Seconda"});
    run(store, {"done", "1"});

    CHECK(nlohmann::json::parse(run(store, {"list", "-f", "json"}).out).size() == 1);
    CHECK(nlohmann::json::parse(run(store, {"list", "-a", "-f", "json"}).out).size() == 2);
}

TEST_CASE("codici di uscita") {
    using Case = std::pair<std::vector<std::string>, int>;
    const auto [args, expected] = GENERATE(
        Case{{}, tasks::exit_usage},
        Case{{"fly"}, tasks::exit_usage},
        Case{{"add"}, tasks::exit_usage},
        Case{{"add", "x", "--priority", "urgent"}, tasks::exit_usage},
        Case{{"list", "--colour"}, tasks::exit_usage},
        Case{{"done", "abc"}, tasks::exit_usage},
        Case{{"done", "0"}, tasks::exit_usage},
        Case{{"done", "42"}, tasks::exit_failure},
        Case{{"list", "--help"}, tasks::exit_ok});

    TempDir dir;
    const tasks::Store store(dir.path / "tasks.json");
    CAPTURE(args);
    CHECK(run(store, args).code == expected);
}

TEST_CASE("gli errori vanno solo su stderr") {
    TempDir dir;
    const tasks::Store store(dir.path / "tasks.json");
    const auto result = run(store, {"rm", "7"});
    CHECK(result.code == tasks::exit_failure);
    CHECK(result.out.empty());
    CHECK(result.err.starts_with("tasks: attività non trovata"));
}
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel
ctest --test-dir build --output-on-failure

Distribuzione

Il risultato della compilazione è un eseguibile nativo, che però su Linux dipende dalle librerie dinamiche della toolchain, in particolare libstdc++ e la libreria C. Per distribuirlo su macchine con versioni diverse delle librerie si possono collegare staticamente le librerie del C++, oppure produrre un eseguibile interamente statico, cosa che funziona al meglio con la libreria C musl, usata ad esempio da Alpine Linux.

# Librerie del C++ statiche, libreria C dinamica
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DTASKS_BUILD_TESTS=OFF \
  -DCMAKE_EXE_LINKER_FLAGS="-static-libstdc++ -static-libgcc"
cmake --build build
ldd build/tasks

# Installazione nel sistema
sudo cmake --install build --prefix /usr/local --strip

Per pacchetti nativi, CMake include CPack, che a partire dalle regole di install genera archivi tar.gz, pacchetti deb e rpm o installer per Windows con poche righe di configurazione aggiuntiva. Come per gli altri linguaggi compilati, ogni piattaforma di destinazione richiede la propria compilazione, gestita tipicamente da una matrice di job nella pipeline di integrazione continua.

Conclusione

Scrivere una CLI in C++ richiede qualche attenzione in più rispetto ai linguaggi con una libreria standard più ricca, ma con le librerie giuste il risultato è altrettanto pulito: CLI11 gestisce sottocomandi, validazione e aiuto, nlohmann/json la serializzazione, CMake con FetchContent le dipendenze. Le insidie specifiche del linguaggio sono quelle su cui abbiamo concentrato l'attenzione: catturare ogni eccezione prima che arrivi a std::terminate, ricondurre i codici di uscita proprietari di CLI11 alla convenzione standard, contare i caratteri e non i byte nell'allineamento delle tabelle, scendere alle API del sistema operativo per una scrittura davvero atomica e gestire i segnali con le poche operazioni consentite nei gestori. Il risultato è l'eseguibile più leggero e veloce dell'intera serie.