Ir al contenido
  1. Posts/

Todo CLI en Rust 4. Creando el CLI con clap: parsing tipado, subcomandos y salida dual

·24 mins
Rafael Fernandez
Autor
Rafael Fernandez
Matemáticas, programación y cosas de la vida
Tabla de contenido
Todo CLI en Rust sin humo - Este artículo es parte de una serie.
Parte 5: Este artículo

Seguimos con la serie.

En los capítulos anteriores llenamos la despensa: persistencia JSON a disco, repositorios intercambiables, y pasamos el control de calidad con tests por comportamiento y deuda técnica documentada. Con los ingredientes conservados, verificados y la cocina equipada, es el momento de subir a la superficie y emplatar el plato: construir la capa que el usuario realmente toca.

Porque aquí la pregunta no es “¿funciona?”, sino “¿alguien que no sea tú querría usarla?”. Un CLI puede ejecutar correctamente todos sus comandos y aún así ser una experiencia terrible: mensajes de error crípticos, argumentos ambiguos, salidas imposibles de parsear con scripts. Eso no es un CLI, es un prototipo que escapó del main.rs.

En esta entrega nos centramos en el emplatado básico: transformar los ingredientes crudos (dominio + casos de uso + persistencia) en algo que se presente bien al comensal, tanto si come con los ojos (terminal) como si manda al pinche a recoger la comanda (scripts y pipelines).

En cocina, el emplatado no cambia el sabor del plato, pero determina si el comensal confía en él antes de probarlo. Con un CLI pasa exactamente lo mismo: la primera impresión es el --help, y si esa salida es confusa, el usuario no va a probar nada más.

todo-cli-en-rust-4-creando-el-cli-con-clap-img-9.png

Código de referencia:

La filosofía: un CLI es un contrato, no un script
#

Antes de lanzarnos al código, vale la pena detenerse a pensar en qué hace que un CLI sea bueno. Y la respuesta corta es: predictibilidad.

Un CLI bueno es un contrato. El usuario (humano o máquina) espera que:

  • Los argumentos inválidos fallen antes de ejecutar nada.
  • Los errores se impriman en stderr, no mezclados con la salida útil en stdout.
  • El código de salida sea 0 en éxito y != 0 en error.
  • La salida tenga una forma estable que no cambie según el humor del desarrollador.

Esto puede parecer obvio, pero la cantidad de CLIs que violan estas reglas es asombrosa. Nosotros vamos a respetar las cuatro desde el primer commit. Y para lograrlo, nos apoyamos en clap.

¿Por qué clap?
#

En el ecosistema Rust hay varias opciones para construir la interfaz de un CLI: clap, argh, o incluso parsear std::env::args() a mano. La elección no es trivial porque el parser de argumentos es, literalmente, la primera línea de código que toca la entrada del usuario.

Elegimos clap por razones concretas:

  1. API derive con macros procedurales. clap ofrece dos APIs: builder (imperativa, construyes el parser llamando métodos encadenados) y derive (declarativa, defines structs/enums y las macros generan el parser). La API derive convierte la definición de argumentos en definiciones de tipos, y eso encaja perfectamente con la filosofía de esta serie: si el tipo compila, el argumento es válido.

  2. ValueEnum para enums cerrados. Con una sola derivación, clap genera validación, autocompletado y ayuda para enums. Ni argh ni el parsing manual ofrecen esto sin código adicional.

  3. Soporte nativo para FromStr. Cualquier tipo que implemente FromStr puede usarse directamente como argumento. Esto significa que Uuid, PathBuf, u64 o cualquier tipo custom se parsean en la frontera de entrada, no dentro de la lógica de negocio.

  4. Ecosistema maduro. clap lleva más de una década en el ecosistema Rust. Tiene documentación extensa, soporte activo y extensiones como clap_complete para generación de autocompletado de shell.

En nuestro Cargo.toml, la dependencia queda así:

[dependencies]
clap = { version = "4.5.59", features = ["derive"] }

El feature flag derive es necesario para activar las macros procedurales (Parser, Subcommand, ValueEnum). Sin él, solo tendrías la API builder disponible.

El struct raíz: Cli y el trait Parser
#

El punto de entrada de todo el parsing es el struct Cli, decorado con #[derive(Parser)]:

use clap::{Parser, Subcommand, ValueEnum};
use uuid::Uuid;

#[derive(Debug, Parser)]
#[command(name = "todo", version, about = "Manage tasks from the terminal")]
pub struct Cli {
    #[arg(long, value_enum, global = true, default_value_t = OutputFormat::Table)]
    pub output: OutputFormat,

    #[command(subcommand)]
    pub command: TodoCommand,
}

Aquí hay bastante información condensada. Vamos por partes.

#[derive(Parser)]
#

Parser es el trait de clap que convierte un struct en un parser de argumentos completo. Cuando escribes Cli::parse(), la macro genera todo el código necesario para leer std::env::args(), validar cada argumento contra las reglas declaradas, y construir una instancia de Cli con valores tipados. Si algo falla, clap imprime un mensaje de error formateado en stderr y termina el proceso con código 2 (convención estándar para errores de uso). Todo esto ocurre antes de que tu código se ejecute.

#[command(name = "todo", version, about = "...")]
#

Este atributo configura metadatos del binario:

  • name = "todo": nombre que aparece en la ayuda (Usage: todo <COMMAND>).
  • version: extrae automáticamente la versión del Cargo.toml (0.1.0), sin tener que duplicarla.
  • about: descripción que aparece en --help.

Un detalle importante: version sin valor explícito usa la macro env!("CARGO_PKG_VERSION") internamente. Esto garantiza que la versión que muestra --version siempre coincide con la del Cargo.toml. Cero mantenimiento manual.

El flag global --output
#

#[arg(long, value_enum, global = true, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,

Este campo merece una explicación atributo por atributo:

  • long: genera el flag --output. Sin short, no hay -o abreviado. Decisión deliberada: -o es ambiguo en muchos CLIs (puede significar “output file”, “output format”, “overwrite”…). Mejor ser explícito.
  • value_enum: indica que los valores posibles se derivan del enum OutputFormat. clap genera automáticamente la lista [table, json] en la ayuda.
  • global = true: este es el atributo clave. Sin él, --output solo estaría disponible antes del subcomando (todo --output json list). Con global = true, el flag se propaga a todos los subcomandos y puede colocarse en cualquier posición: todo --output json list y todo list --output json son equivalentes. Esto evita duplicar la definición de --output en cada variante de TodoCommand.
  • default_value_t = OutputFormat::Table: si el usuario no pasa --output, se asume Table. Nótese que usamos default_value_t (con _t de “typed”) y no default_value. La diferencia es que default_value espera un &str y lo parsea en runtime, mientras que default_value_t acepta directamente el valor del tipo. Si el tipo del campo y el valor por defecto no coinciden, el compilador lo rechaza. Otro caso más donde movemos la validación al compilador.

#[command(subcommand)]
#

#[command(subcommand)]
pub command: TodoCommand,

Este atributo indica que el campo command no es un argumento simple sino un subcomando que se expande con su propia estructura de argumentos. El tipo TodoCommand debe derivar Subcommand.

Subcomandos como variantes de un enum
#

Los subcomandos se definen como variantes de TodoCommand:

#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum TodoCommand {
    Add {
        title: String,
    },
    List {
        #[arg(long, value_enum, default_value_t = StatusArg::All)]
        status: StatusArg,
    },
    Done {
        id: Uuid,
    },
    Todo {
        id: Uuid,
    },
    Delete {
        id: Uuid,
    },
}

Fíjate: esto es la especificación del contrato. No hay documentación separada que pueda quedar desactualizada. La firma del enum define exactamente qué acepta cada comando. Si mañana añades un subcomando Edit { id: Uuid, title: String }, la ayuda y la validación se actualizan automáticamente.

Decisiones de diseño en los subcomandos
#

Hay decisiones técnicas intencionadas en este enum:

Add { title: String } — argumento posicional sin flag:

title no tiene #[arg(long)], así que clap lo trata como argumento posicional. El usuario escribe todo add "Comprar leche" en vez de todo add --title "Comprar leche". Es más natural para un comando de creación rápida. La validación de contenido (título vacío, título demasiado largo) no ocurre aquí: eso es responsabilidad del dominio. La CLI solo garantiza que llega un String; el dominio decide si es válido.

List { status: StatusArg } — flag opcional con valor por defecto:

status es un flag (--status) con default_value_t = StatusArg::All. Esto permite tres formas de uso:

  • todo list → lista todas las tareas (default All).
  • todo list --status todo → solo tareas pendientes.
  • todo list --status done → solo tareas completadas.

¿Por qué flag y no posicional? Porque un filtro opcional es semánticamente un modificador del comportamiento, no el sujeto del comando. Los posicionales comunican “qué”, los flags comunican “cómo”.

Done { id: Uuid }, Todo { id: Uuid }, Delete { id: Uuid } — UUID como tipo nativo:

Esto es sutil pero fundamental. clap sabe parsear Uuid porque el crate uuid implementa el trait FromStr:

impl FromStr for Uuid {
    type Err = uuid::Error;
    fn from_str(s: &str) -> Result<Self, Self::Err> { /* ... */ }
}

clap llama internamente a Uuid::from_str() durante el parsing. Si el usuario escribe todo done abc123, clap falla inmediatamente con un mensaje de error que incluye el valor inválido y el formato esperado, antes de que se instancie ningún servicio ni se abra el archivo de persistencia. No hay necesidad de validar el UUID dentro del caso de uso, donde ya deberíamos estar hablando de negocio, no de parsing.

Esta es la misma filosofía de “validar en la frontera más externa” que aplicamos con ValueEnum, pero extendida a cualquier tipo que implemente FromStr. Si mañana tuvieras un tipo ProjectId custom, bastaría con implementar FromStr para él y usarlo directamente en el enum de subcomandos.

ValueEnum: enums cerrados validados por clap
#

Tanto StatusArg como OutputFormat son enums con #[derive(ValueEnum)]:

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
    Table,
    Json,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum StatusArg {
    All,
    Todo,
    Done,
}

ValueEnum le dice a clap: “los únicos valores válidos son las variantes de este enum”. Internamente, clap genera una implementación que convierte el nombre de cada variante a kebab-case por defecto (aunque para variantes de una sola palabra como Table, Json, All, Todo, Done, el resultado es simplemente lowercase: table, json, all, todo, done).

¿Qué ganamos frente a recibir un String y hacer match manual?

  • Validación automática. Si el usuario escribe --status haciendo, clap rechaza el valor con un mensaje claro antes de que se ejecute ninguna lógica de negocio. El mensaje incluye la lista de valores posibles.
  • Autocompletado y --help gratis. clap genera automáticamente [possible values: all, todo, done] en la ayuda. Si en el futuro añades una variante InProgress, aparece en --help sin tocar nada más.
  • Cero mantenimiento de mensajes de error. No hay ningún eprintln!("Invalid status: {}", s) escrito a mano que pueda quedar desalineado cuando modifiques el enum.
  • Exhaustividad en compilación. Al usar match sobre un ValueEnum, el compilador te avisa si olvidas cubrir una variante. Con String, ese match tendría que incluir un brazo _ => catch-all que silencia errores futuros.

Además, observa que ambos enums derivan Copy. Son tipos de tamaño cero en la práctica (el enum se almacena como un u8 internamente). Pasarlos por valor es más eficiente que por referencia, y permite usarlos en contextos const o en pattern matching sin preocuparse por lifetimes.

De la CLI al caso de uso: la conversión From
#

Ahora llegamos a un punto de diseño clave que conecta la capa CLI con la capa de aplicación sin acoplarlas. Recordemos: en nuestra arquitectura hexagonal, StatusArg pertenece al adaptador CLI y FilterTask / ListTasksCommand pertenecen a la capa de aplicación. Son conceptos del mismo universo semántico, pero de capas distintas.

La traducción entre ambos mundos se resuelve con una implementación de From:

impl From<StatusArg> for ListTasksCommand {
    fn from(value: StatusArg) -> Self {
        Self::new(status_command_to_filter_task(value))
    }
}

pub fn status_command_to_filter_task(command: StatusArg) -> FilterTask {
    match command {
        StatusArg::All => FilterTask::All,
        StatusArg::Todo => FilterTask::Todo,
        StatusArg::Done => FilterTask::Done,
    }
}

¿Por qué no usar directamente FilterTask como argumento del CLI? Porque estarías rompiendo la inversión de dependencias. Si cli_command.rs usara FilterTask directamente en el enum TodoCommand, el adaptador CLI importaría tipos de la capa de aplicación dentro de su definición de parsing. Eso significa que un cambio en la capa de aplicación (añadir FilterTask::Overdue, renombrar FilterTask a TaskFilter) rompería el parser.

Con From, la dependencia fluye en la dirección correcta: el adaptador CLI conoce los tipos de la aplicación solo en el punto de conversión, no en la definición de la interfaz de usuario. Si mañana la capa de aplicación añade un filtro FilterTask::Overdue, la CLI no se rompe: simplemente no ofrecería esa opción hasta que alguien la exponga como variante de StatusArg.

En main.rs, la conversión se usa con .from() de forma explícita:

TodoCommand::List { status } => {
    let list_service = ListTasksService::new(repository);
    let tasks: Vec<Task> = list_service.execute(ListTasksCommand::from(status))?;
    print_tasks(&tasks, cli.output)
}

ListTasksCommand::from(status) traduce de StatusArg (tipo CLI) a ListTasksCommand (tipo aplicación) en una sola llamada. Limpio, explícito, y trazable.

Tests de contrato: try_parse_from como herramienta de verificación
#

El módulo cli_command.rs incluye una batería de tests que merece análisis. No son tests unitarios de lógica de negocio: son tests de contrato que verifican que la interfaz pública del CLI se comporta como se prometió.

La pieza clave es Cli::try_parse_from(). A diferencia de Cli::parse() (que termina el proceso si falla), try_parse_from devuelve un Result que podemos inspeccionar en tests:

#[test]
fn parses_add_command() {
    let cli = Cli::try_parse_from(["todo", "add", "Buy milk"])
        .expect("cli should parse add");
    assert_eq!(cli.output, OutputFormat::Table);
    assert_eq!(
        cli.command,
        TodoCommand::Add { title: "Buy milk".to_string() }
    );
}

Este test verifica tres cosas simultáneamente: (1) el subcomando add se parsea correctamente, (2) el título posicional se captura como String, y (3) el formato de salida por defecto es Table. Fíjate que el primer elemento del array ("todo") simula el nombre del binario, que clap consume pero no procesa como argumento.

#[test]
fn parses_list_command_with_default_status() {
    let cli = Cli::try_parse_from(["todo", "list"])
        .expect("cli should parse list");
    assert_eq!(cli.command, TodoCommand::List { status: StatusArg::All });
}

#[test]
fn parses_list_command_with_explicit_status() {
    let cli = Cli::try_parse_from(["todo", "list", "--status", "done"])
        .expect("cli should parse list with status");
    assert_eq!(cli.command, TodoCommand::List { status: StatusArg::Done });
}

Estos dos tests documentan el comportamiento del default_value_t: sin --status, se asume All; con --status done, se parsea Done.

#[test]
fn parses_done_command_with_uuid() {
    let id = Uuid::new_v4();
    let cli = Cli::try_parse_from(["todo", "done", &id.to_string()])
        .expect("cli should parse done");
    assert_eq!(cli.command, TodoCommand::Done { id });
}

Este test genera un UUID v4 aleatorio, lo convierte a string, lo pasa como argumento, y verifica que el Uuid parseado coincide con el original. Es la prueba de que la integración clap + uuid::FromStr funciona correctamente en ambas direcciones.

#[test]
fn parses_global_output_flag() {
    let cli = Cli::try_parse_from(["todo", "--output", "json", "list"])
        .expect("cli should parse global output");
    assert_eq!(cli.output, OutputFormat::Json);
    assert_eq!(cli.command, TodoCommand::List { status: StatusArg::All });
}

Este test verifica el comportamiento de global = true: el flag --output json se coloca antes del subcomando y aún así se asocia correctamente al campo output del struct Cli.

#[test]
fn rejects_invalid_uuid_for_done_command() {
    let parsed = Cli::try_parse_from(["todo", "done", "not-a-uuid"]);
    assert!(parsed.is_err());
}

Y el más revelador: testear que el CLI rechaza correctamente entradas malformadas. No estamos validando lógica de negocio, estamos asegurando que la frontera de entrada filtra basura antes de que llegue al interior del sistema. Esto es testing de contrato puro.

Puedes ver la batería completa de tests en el módulo de tests de cli_command.rs.

Errores de la capa CLI: cerrando la cadena
#

Cada capa de la arquitectura tiene su propio tipo de error. Lo vimos en detalle en el post anterior, y la capa CLI cierra esa cadena como el último eslabón:

use crate::tasks::application::errors::ApplicationError;
use thiserror::Error;

pub type CliResult<T> = Result<T, CliError>;

#[derive(Debug, Error)]
pub enum CliError {
    #[error(transparent)]
    Application(#[from] ApplicationError),
    #[error(transparent)]
    Serializer(#[from] serde_json::Error),
}

CliError tiene exactamente dos variantes, y eso no es casualidad. Son las únicas dos cosas que pueden fallar en la capa CLI una vez superado el parsing de argumentos (que clap maneja internamente):

  • Application: cualquier error que venga de los casos de uso. Gracias al #[from], el operador ? convierte automáticamente un ApplicationError en CliError::Application. Y ese ApplicationError a su vez puede contener un DomainError o un RepoError, cada uno con su mensaje específico.
  • Serializer: errores de serde_json al serializar la salida. Solo ocurre en modo --output json. El #[from] permite que un serde_json::Error se convierta automáticamente en CliError::Serializer vía ?.

El atributo #[error(transparent)] delega el formateo del mensaje al error interno. Esto significa que si el dominio produce un DomainError::EmptyTitle, el usuario final ve exactamente ese mensaje: "task title cannot be empty". Sin capas de wrapping innecesarias, sin prefijos genéricos como "CLI error: Application error: Domain error: ...".

La cadena completa de errores queda así:

CliError
  ├── ApplicationError
  │     ├── DomainError    (EmptyTitle, TitleTooLong, TaskNotFound, InvalidStatusTransition)
  │     └── RepoError      (errores de I/O, serialización de persistencia)
  └── serde_json::Error    (errores de serialización de salida JSON)

Cada capa captura los errores de la capa inferior con #[from] y el operador ? propaga limpiamente hacia arriba. No hay unwrap(), no hay panic!(), no hay strings construidos a mano. El type alias CliResult<T> simplifica las firmas de todas las funciones del adaptador CLI.

Salida dual: el módulo printer
#

En muchos CLIs se imprime texto bonito y luego alguien intenta automatizar y descubre que no hay contrato estable de salida. Parsear con grep y awk funciona hasta que alguien cambia un espacio, una mayúscula o el ancho de una columna y todo se rompe.

Aquí resolvimos eso desde el diseño con el módulo printer, que separa completamente la lógica de presentación de la lógica de negocio. El printer recibe datos ya procesados (un &Task, un &[Task], o un bool de delete) y los formatea según el OutputFormat que eligió el usuario.

pub fn print_task(task: &Task, output: OutputFormat) -> CliResult<()> {
    match output {
        OutputFormat::Json => {
            println!("{}", serde_json::to_string(task)?);
        }
        OutputFormat::Table => {
            print_tasks_table(std::slice::from_ref(task));
        }
    }
    Ok(())
}

pub fn print_tasks(tasks: &[Task], output: OutputFormat) -> CliResult<()> {
    match output {
        OutputFormat::Json => {
            println!("{}", serde_json::to_string(tasks)?);
        }
        OutputFormat::Table => {
            print_tasks_table(tasks);
        }
    }
    Ok(())
}

Dos formatos, una sola función por operación:

  • table: lectura rápida en terminal para humanos. Columnas alineadas dinámicamente.
  • json: payload estable para scripts, pipelines CI/CD o integración con herramientas como jq.

Ejemplo de uso:

cargo run -- list
cargo run -- --output json list --status done

std::slice::from_ref: reutilización sin allocation
#

Un detalle técnico que merece atención: en print_task, cuando la operación devuelve una sola tarea (add, done, todo), en vez de duplicar la lógica de formateo o crear un vec![task] temporal, usamos std::slice::from_ref(task).

Esta función de la librería estándar convierte una referencia &T en un slice &[T] de un solo elemento. Es zero-cost: no hay allocation en heap, no hay copia, no hay Vec. Solo una reinterpretación del puntero. Esto nos permite reutilizar print_tasks_table para todos los casos sin penalización de rendimiento.

Tabla dinámica con anchos calculados
#

La función print_tasks_table calcula los anchos de columna basándose en el contenido real:

fn print_tasks_table(tasks: &[Task]) {
    let id_header = "ID";
    let status_header = "STATUS";
    let title_header = "TITLE";

    let id_width = tasks
        .iter()
        .map(|task| task.task_id().to_string().len())
        .max()
        .unwrap_or(0)
        .max(id_header.len());
    let status_width = tasks
        .iter()
        .map(|task| status_label(task).len())
        .max()
        .unwrap_or(0)
        .max(status_header.len());
    let title_width = tasks
        .iter()
        .map(|task| task.title().len())
        .max()
        .unwrap_or(0)
        .max(title_header.len());

    println!(
        "| {:<id_width$} | {:<status_width$} | {:<title_width$} |",
        id_header, status_header, title_header
    );
    println!(
        "|-{:-<id_width$}-|-{:-<status_width$}-|-{:-<title_width$}-|",
        "", "", ""
    );

    for task in tasks {
        let id = task.task_id().to_string();
        println!(
            "| {:<id_width$} | {:<status_width$} | {:<title_width$} |",
            id, status_label(task), task.title()
        );
    }
}

fn status_label(task: &Task) -> &'static str {
    match task.status() {
        TaskStatus::Todo => "TODO",
        TaskStatus::Done => "DONE",
    }
}

El patrón de cada columna es el mismo: recorre todas las tareas, toma la longitud máxima del campo, y la compara con la longitud del header usando .max(header.len()). Así el ancho de cada columna nunca es menor que su cabecera, pero se expande si algún dato lo necesita.

No hay anchos fijos hardcodeados. Un UUID v4 tiene siempre 36 caracteres, pero si mañana cambiases a un ID más corto (o más largo), la tabla se adapta automáticamente. Los formateadores {:<id_width$} usan la sintaxis de ancho dinámico de Rust ($ indica que el valor de ancho viene de una variable, no de un literal).

¿Por qué no usar un crate como prettytable-rs o tabled? Porque para tres columnas con datos simples, la implementación manual son 30 líneas sin dependencias adicionales. Añadir un crate entero para esto habría sido over-engineering. Si la tabla creciera a 10 columnas con colores y bordes, la decisión sería diferente.

La función auxiliar status_label
#

Fíjate en el tipo de retorno de status_label: &'static str. Es una referencia a un string literal embebido en el binario. No hay allocation, no hay conversión de enum a String. Es el patrón más eficiente para mapear enums a etiquetas de texto fijas.

Caso interesante: delete y la idempotencia visible
#

delete es el subcomando más interesante desde el punto de vista de diseño de contrato. ¿Qué pasa si intentas borrar una tarea que no existe? Hay dos escuelas:

  1. Lanzar error (404-style): “la tarea no existe, estás haciendo algo mal”.
  2. Respuesta idempotente: “no había nada que borrar, pero no es un error”.

Nosotros elegimos la segunda, pero con visibilidad explícita. La razón está en cómo se consume este comando en la práctica:

  • Un humano que ejecuta todo delete <id> dos veces probablemente cometió un error la segunda vez, pero no quiere ver un stacktrace por ello.
  • Un script de limpieza que itera sobre una lista de IDs para borrarlos no debería romperse porque uno ya fue eliminado en una ejecución anterior.

El principio es el mismo que sigue rm -f en Unix: no falla si el archivo no existe, pero tampoco lo oculta.

En el repositorio, el caso de uso delete_task.rs devuelve un bool:

impl<R: TaskRepository> DeleteTaskUseCase for DeleteTaskService<R> {
    fn execute(&mut self, cmd: DeleteTaskCommand) -> ApplicationResult<bool> {
        let task_id: Uuid = cmd.task_id;
        Ok(self.repo.delete(task_id)?)
    }
}

Y el trait TaskRepository define delete como:

fn delete(&mut self, id: Uuid) -> RepoResult<bool>;

El bool viaja desde el repositorio, pasa por el caso de uso sin transformación, y llega al printer, donde se traduce a una respuesta visible:

pub fn print_delete(id: String, deleted: bool, output: OutputFormat) -> CliResult<()> {
    let message = if deleted {
        format!("deleted {id}")
    } else {
        format!("task {id} not found")
    };

    match output {
        OutputFormat::Json => {
            let payload = DeleteOutput { id, deleted, message };
            println!("{}", serde_json::to_string(&payload)?);
        }
        OutputFormat::Table => {
            let result = if deleted { "DELETED" } else { "NOT_FOUND" };
            println!("| RESULT    | MESSAGE            |");
            println!("|-----------|--------------------|");
            println!("| {result:<9} | {message} |");
        }
    }
    Ok(())
}

#[derive(Debug, Serialize)]
struct DeleteOutput {
    id: String,
    deleted: bool,
    message: String,
}

El struct DeleteOutput serializa a JSON un payload con tres campos explícitos. Un script puede hacer jq .deleted y tomar decisiones sin parsear texto. En modo table, el humano ve DELETED o NOT_FOUND de un vistazo.

Observa que en ambos casos el código de salida es 0 (éxito). El proceso no falló, simplemente no había nada que borrar. Si quisieras diferenciar estos casos en un pipeline, inspeccionarías el campo deleted del JSON, no el exit code. Eso es un contrato limpio.

Orquestación en main.rs: solo cableado, cero decisiones
#

El main.rs es la prueba de fuego de toda la arquitectura. Si has diseñado bien las capas, el punto de entrada debería ser aburrido. Veamos:

pub fn main() {
    if let Err(error) = run() {
        eprintln!("{error}");
        std::process::exit(1);
    }
}

main() llama a run(). Si falla, imprime el error en stderr (no stdout) y sale con código 1. Tres líneas. Es el contrato estándar de Unix que cualquier script, pipeline de CI/CD o shell wrapper espera.

Observa que main() no devuelve Result. Podríamos haber usado fn main() -> Result<(), CliError>, pero eso delega el formateo del error a la implementación de Debug (o Display con la feature termination), lo cual produce mensajes menos controlados. Con el if let Err, tenemos control total sobre cómo se muestra el error al usuario.

La función run() es donde vive toda la orquestación:

fn run() -> CliResult<()> {
    let cli = Cli::parse();
    let repository = JsonFileTaskRepository::new()
        .map_err(ApplicationError::Repository)?;

    match cli.command {
        TodoCommand::Add { title } => {
            let mut add_service = AddTaskService::new(repository);
            let task: Task = add_service.execute(AddTaskCommand::new(title))?;
            print_task(&task, cli.output)
        }
        TodoCommand::List { status } => {
            let list_service = ListTasksService::new(repository);
            let tasks: Vec<Task> = list_service.execute(ListTasksCommand::from(status))?;
            print_tasks(&tasks, cli.output)
        }
        TodoCommand::Done { id } => {
            let mut mark_task_done_service = MarkTaskDoneService::new(repository);
            let task = mark_task_done_service.execute(MarkTaskDoneCommand::new(id))?;
            print_task(&task, cli.output)
        }
        TodoCommand::Todo { id } => {
            let mut mark_task_todo_service = MarkTaskTodoService::new(repository);
            let task = mark_task_todo_service.execute(MarkTaskTodoCommand::new(id))?;
            print_task(&task, cli.output)
        }
        TodoCommand::Delete { id } => {
            let mut delete_service = DeleteTaskService::new(repository);
            let deleted = delete_service.execute(DeleteTaskCommand::new(id))?;
            print_delete(id.to_string(), deleted, cli.output)
        }
    }
}

run() hace exactamente tres cosas y nada más:

  1. Parsea el comando (Cli::parse()). Si los argumentos son inválidos, clap termina el proceso aquí con un mensaje de error y código 2. run() nunca llega a ejecutarse.
  2. Instancia el repositorio (JsonFileTaskRepository::new()). Este es el único punto donde se decide qué adaptador de persistencia concreto se usa. El .map_err(ApplicationError::Repository) convierte el RepoError en ApplicationError, que el ? propaga como CliError::Application.
  3. Despacha al caso de uso correspondiente y formatea la salida. Cada brazo del match sigue el mismo patrón:
instanciar servicio -> ejecutar con Command -> formatear salida

No hay reglas de negocio. No hay validaciones. No hay transformaciones de datos. Solo cableado. Esta uniformidad no es accidental. Es consecuencia directa de tres decisiones de diseño que hemos tomado en los posts anteriores:

  • El Command Pattern en los casos de uso garantiza que cada servicio tiene una interfaz uniforme (execute(Command) -> Result<T>).
  • La inversión de dependencias con TaskRepository permite inyectar el repositorio concreto sin que run() conozca los detalles de persistencia.
  • El módulo printer separa el formateo de la orquestación.

El docstring como especificación
#

Un detalle que merece mención: el main.rs incluye un docstring extenso que documenta el contrato completo del CLI como comentario de la función main:

/// CLI Contract v0.1
/// - add <title>
///   - Input: title: String (required)
///   - Rules: title must not be empty/blank
///   - Success output: created task summary (id, title, status)
///   - Error output: validation error when title is empty
/// - list [--status <all|todo|done>]
///   - Input: optional status flag
///   - Default: all
///   - Success output: one line per task with status + id + title
///   - Error output: invalid status value (argument parsing)
/// ...

Esto no es casual. Es un contrato versionado (v0.1) que documenta inputs, reglas, outputs esperados y posibles errores de cada comando. Es una práctica que recomendaría en cualquier CLI: antes de escribir el código, documenta el contrato. Y hazlo en el mismo archivo donde vive la orquestación, para que el contrato esté siempre visible cuando alguien abra el punto de entrada.

Estructura del módulo CLI dentro de la arquitectura
#

Vale la pena ver dónde vive todo esto en el árbol de directorios del proyecto:

src/tasks/adapters/cli/
├── mod.rs              # Declaración de los submódulos
├── cli_command.rs      # Struct Cli, enum TodoCommand, enums ValueEnum, tests
├── printer.rs          # Funciones de formateo: print_task, print_tasks, print_delete
└── errors.rs           # CliError, CliResult

El mod.rs es mínimo:

pub mod cli_command;
pub mod errors;
pub mod printer;

Tres módulos, cada uno con una responsabilidad clara:

  • cli_command: parsing y validación de entrada (el “qué” entra al sistema).
  • printer: formateo de salida (el “qué” sale del sistema).
  • errors: tipo de error de la capa (el “qué” puede fallar en esta capa).

Esta estructura espeja la separación Input/Output que es inherente a cualquier adaptador en arquitectura hexagonal. El CLI es un adaptador primario (o “driving adapter”): inicia la acción. No es el sistema quien decide cuándo ejecutarse, es el usuario quien lo invoca. Pero a nivel de responsabilidades, sigue teniendo una cara de entrada (parsing) y una cara de salida (formateo), y las hemos separado en módulos distintos.

Complemento con historial del repo
#

Este capítulo se conecta especialmente bien con este commit:

Si lo revisas, se ve claramente el salto de “CLI funcional” a “CLI usable por humanos y scripts”. El diff muestra exactamente las decisiones que hemos explicado aquí: la introducción del flag --output, la separación del módulo printer y la salida dual.

Conclusión: el emplatado importa tanto como la receta
#

Volviendo a nuestra metáfora de cocina: puedes tener los mejores ingredientes del mercado (dominio inmutable) y una receta impecable (casos de uso limpios), pero si el plato llega a la mesa mal emplatado, frío o sin los cubiertos adecuados, la experiencia del comensal es mala.

Lo mismo ocurre con una herramienta de terminal. El dominio puede ser perfecto, pero si el usuario tiene que adivinar qué argumentos acepta, leer errores crípticos, o inventar regex para parsear la salida, la herramienta no se usa. Y una herramienta que no se usa es código muerto.

En este capítulo hemos diseñado un adaptador de entrada que:

  • Delega el parsing a clap derive: la definición de tipos es la especificación del contrato. Si compila, los argumentos son válidos.
  • Valida en la frontera con ValueEnum y FromStr: enums cerrados para opciones finitas, tipos semánticos (como Uuid) para argumentos estructurados. Todo rechazado antes de tocar lógica de negocio.
  • Separa tipos por capa: StatusArg (CLI) se convierte a FilterTask (aplicación) vía From, preservando la inversión de dependencias.
  • Propaga errores sin ruido: la cadena CliError -> ApplicationError -> DomainError/RepoError fluye limpiamente con ? y #[error(transparent)].
  • Ofrece dos contratos de salida: --output table para humanos, --output json para máquinas. Un solo flag, decidido por el consumidor.
  • Mantiene main.rs como puro cableado: sin lógica, sin decisiones de negocio, solo conexiones entre parsing, servicio y printer.

Con el plato presentado en la mesa y el comensal satisfecho, tenemos un CLI funcional de extremo a extremo: dominio inmutable, persistencia JSON, casos de uso limpios y una interfaz de terminal con contrato estable. Pero hay un detalle: el comensal come con los ojos. Y nuestra presentación actual es texto plano en stdout.

En el siguiente post vamos a dar el salto de comida casera a estrella Michelin: migrar de CLI puro a una interfaz TUI interactiva con ratatui. Misma cocina, mismos ingredientes, misma receta, pero con un emplatado que eleva la experiencia a otro nivel.

¡Nos vemos en la cocina!

Todo CLI en Rust sin humo - Este artículo es parte de una serie.
Parte 5: Este artículo