Projeto Tarefas com Maven

O último projeto é um programa "de verdade", do tipo que se usa no terminal todo dia: um gerenciador de tarefas. Nada de menu nem de perguntas: você diz o que quer na própria linha de comando (tarefas add "Comprar pão", tarefas list, tarefas done 1), ele faz e termina. As tarefas ficam num arquivo CSV, e o programa inteiro vira um único JAR executável.

Você vai praticar
  • Ler argumentos da linha de comando e montar subcomandos com switch.
  • Avisar erros como um programa de terminal: System.err e código de saída com System.exit.
  • Um record que vira linha de CSV e volta, com Files (sem bibliotecas externas).
  • Streams para filtrar e contar.
  • Organizar o projeto com Maven e gerar um JAR executável.

O que vamos construir

O programa pronto. Depois de criar um alias (um apelido do Bash), a Ana usa o tarefas de qualquer pasta, como se fosse um comando do sistema:

$ java -jar target/tarefas.jar list
[x] 1. Comprar pão
[ ] 2. Estudar streams; capítulo 3
[ ] 3. Pagar a conta de luz
3 tarefa(s), 2 pendente(s).
$ alias tarefas='java -jar ~/java/tarefas/target/tarefas.jar'
$ cd
$ tarefas done 2
Concluída: [x] 2. Estudar streams; capítulo 3
$ tarefas list --pendentes
[ ] 3. Pagar a conta de luz
3 tarefa(s), 1 pendente(s).
$ tarefas
Uso: tarefas add "descrição" | list [--pendentes|--feitas] | done NÚMERO
  1. O projeto e os argumentos (args).
  2. Os subcomandos (switch, System.err, System.exit).
  3. Guardar em CSV (record Tarefa, classe Arquivo).
  4. Concluir e filtrar (done, streams).
  5. O JAR executável (maven-jar-plugin, java -jar).

Antes de começar

Você vai usar Maven (como no projeto do banco), args, Streams e, para o CSV, o que viu no projeto da agenda. A estrutura:

$ mkdir -p java/tarefas/src/main/java/br/com/ana/tarefas
$ cd java/tarefas
As missões simulam a linha de comando

O editor do guia não roda Maven. As caixas Experimente usam programas de um arquivo só, e os argumentos da linha de comando vêm prontos na missão (como se você tivesse digitado java Main.java list). As saídas de mvn e java -jar desta página são reais, da máquina da Ana.


Etapa 1 · O projeto e os argumentos

Primeiro, o pom.xml mais simples possível (sem dependências: este projeto não usa bibliotecas externas):

<?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>br.com.ana</groupId>
    <artifactId>tarefas</artifactId>
    <version>1.0</version>

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

</project>

E uma classe que só mostra o que recebeu. Todo main recebe String[] args: um array com as palavras digitadas depois do nome da classe.

package br.com.ana.tarefas;

public class App {
    public static void main(String[] args) {
        System.out.println("Recebi " + args.length + " argumento(s):");
        for (int i = 0; i < args.length; i++) {
            System.out.println("args[" + i + "] = " + args[i]);
        }
    }
}
$ mvn -q compile
$ java -cp target/classes br.com.ana.tarefas.App add "Comprar pão"
Recebi 2 argumento(s):
args[0] = add
args[1] = Comprar pão
$ java -cp target/classes br.com.ana.tarefas.App add Comprar pão
Recebi 3 argumento(s):
args[0] = add
args[1] = Comprar
args[2] = pão                             « sem aspas, cada palavra é um argumento
$ java -cp target/classes br.com.ana.tarefas.App
Recebi 0 argumento(s):

Quem corta a linha em argumentos é o Bash, não o Java: ele separa nos espaços, e as aspas juntam o que está dentro num argumento só (veja Argumentos da Linha de Comando). Por isso o programa vai pedir a descrição entre aspas. O args[0] será sempre o subcomando (add, list, done), do mesmo jeito que git commit ou apt install.

Quando dá errado: ler args[0] sem argumentos

Se o programa fizer String comando = args[0]; direto e a pessoa não digitar nada:

$ java -cp target/classes br.com.ana.tarefas.App
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
	at br.com.ana.tarefas.App.main(App.java:5)

"Índice 0 fora dos limites para tamanho 0": o array args existe, mas está vazio. Confira args.length antes de ler qualquer posição.

Missão (etapa 1): a missão roda o programa como java Main.java list. Se não houver argumentos, mostre Sem comando; se houver, mostre Comando: seguido de args[0].

public class Main {
    public static void main(String[] args) {
        System.out.println("Recebi " + args.length + " argumento(s).");
        // se não houver argumentos, mostre "Sem comando";
        // se houver, mostre "Comando: " seguido do primeiro argumento
    }
}
public class Main {
    public static void main(String[] args) {
        System.out.println("Recebi " + args.length + " argumento(s).");
        if (args.length == 0) {
            System.out.println("Sem comando");
        } else {
            System.out.println("Comando: " + args[0]);
        }
    }
}

Etapa 2 · Os subcomandos

Agora o args[0] escolhe o que fazer. Programas de terminal seguem duas convenções que vamos respeitar:

  • Mensagens de erro vão para a saída de erro, System.err, e não para a saída normal, System.out. Assim, quem redireciona a saída para um arquivo (> lista.txt) não mistura erros com dados.
  • O código de saída diz se deu certo: 0 é sucesso, qualquer outro número é erro. Scripts e o próprio Bash (&&, if) olham esse número. System.exit(1) termina o programa na hora, com o código 1.
package br.com.ana.tarefas;

import java.util.ArrayList;
import java.util.List;

public class App {
    public static void main(String[] args) {
        if (args.length == 0) {
            uso();
            System.exit(1);
        }
        List<String> tarefas = new ArrayList<>();

        switch (args[0]) {
            case "add" -> {
                tarefas.add(args[1]);
                System.out.println("Adicionada: " + args[1]);
            }
            case "list" -> {
                for (String t : tarefas) {
                    System.out.println(t);
                }
                System.out.println(tarefas.size() + " tarefa(s).");
            }
            default -> {
                System.err.println("Comando desconhecido: " + args[0]);
                uso();
                System.exit(1);
            }
        }
    }

    static void uso() {
        System.err.println("Uso: tarefas add \"descrição\" | list");
    }
}
$ java -cp target/classes br.com.ana.tarefas.App add "Comprar pão"
Adicionada: Comprar pão
$ java -cp target/classes br.com.ana.tarefas.App list
0 tarefa(s).                              « cadê o pão?
$ java -cp target/classes br.com.ana.tarefas.App
Uso: tarefas add "descrição" | list
$ echo $?
1                                         « o código de saída do último comando
$ java -cp target/classes br.com.ana.tarefas.App apagar
Comando desconhecido: apagar
Uso: tarefas add "descrição" | list
$ echo $?
1

O echo $? é do Bash: mostra o código de saída do comando anterior. E o pão sumiu: cada java … é um programa novo, que começa com a lista vazia e a perde ao terminar. Para lembrar entre uma execução e outra, só gravando num arquivo, na próxima etapa.

Missão (etapa 2): a missão roda java Main.java apagar, e o programa termina calado, com código 0, como se tivesse dado certo. Acrescente um default que escreve Comando desconhecido: apagar em System.err e sai com System.exit(1).

public class Main {
    public static void main(String[] args) {
        if (args.length == 0) {
            System.err.println("Uso: tarefas add \"descrição\" | list");
            System.exit(1);
        }
        switch (args[0]) {
            case "add" -> System.out.println("Adicionar: " + args[1]);
            case "list" -> System.out.println("Listar");
        }
    }
}
public class Main {
    public static void main(String[] args) {
        if (args.length == 0) {
            System.err.println("Uso: tarefas add \"descrição\" | list");
            System.exit(1);
        }
        switch (args[0]) {
            case "add" -> System.out.println("Adicionar: " + args[1]);
            case "list" -> System.out.println("Listar");
            default -> {
                System.err.println("Comando desconhecido: " + args[0]);
                System.exit(1);
            }
        }
    }
}

Etapa 3 · Guardar em CSV

CSV (comma-separated values, "valores separados por vírgula") é o formato de planilha mais simples: uma linha por registro, campos separados por um caractere. Como em português a vírgula aparece nos textos, usamos ;. Cada tarefa vira uma linha id;feita;descrição:

1;false;Comprar pão
2;false;Estudar streams; capítulo 3

A tarefa é um record que sabe virar linha e voltar:

package br.com.ana.tarefas;

public record Tarefa(int id, String descricao, boolean feita) {

    public String paraLinha() {
        return id + ";" + feita + ";" + descricao;
    }

    public static Tarefa deLinha(String linha) {
        String[] partes = linha.split(";", 3);
        return new Tarefa(Integer.parseInt(partes[0]), partes[2], Boolean.parseBoolean(partes[1]));
    }

    @Override
    public String toString() {
        return (feita ? "[x] " : "[ ] ") + id + ". " + descricao;
    }
}
paraLinha()
Junta os campos com ;. A descrição fica por último de propósito (veja split abaixo).
static Tarefa deLinha(String linha)
Um método static que fabrica uma tarefa a partir de uma linha. É chamado pelo nome do tipo: Tarefa.deLinha(…).
linha.split(";", 3)
Corta em no máximo 3 pedaços: id, feita e "todo o resto". Se a descrição tiver ; (como Estudar streams; capítulo 3), ela fica inteira no terceiro pedaço.
Integer.parseInt / Boolean.parseBoolean
Convertem o texto "1" no int 1 e "true" no boolean true (wrappers).
toString()
O record já tem um toString, mas aqui escrevemos o nosso: [ ] 1. Comprar pão, ou [x] se estiver feita.

A leitura e gravação do arquivo ficam numa classe separada, para o App não se preocupar com isso:

package br.com.ana.tarefas;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public class Arquivo {
    private final Path caminho;

    public Arquivo(Path caminho) {
        this.caminho = caminho;
    }

    public List<Tarefa> carregar() throws IOException {
        List<Tarefa> tarefas = new ArrayList<>();
        if (Files.exists(caminho)) {
            for (String linha : Files.readAllLines(caminho)) {
                if (!linha.isBlank()) {
                    tarefas.add(Tarefa.deLinha(linha));
                }
            }
        }
        return tarefas;
    }

    public void salvar(List<Tarefa> tarefas) throws IOException {
        List<String> linhas = new ArrayList<>();
        for (Tarefa t : tarefas) {
            linhas.add(t.paraLinha());
        }
        Files.write(caminho, linhas);
    }
}

Os dois métodos declaram throws IOException: em vez de tratar o erro aqui, deixam o App decidir. E o App decide deixar a exceção subir (main(…) throws IOException): se o disco falhar, o programa termina com o erro na tela, o que é razoável para uma ferramenta de terminal.

package br.com.ana.tarefas;

import java.io.IOException;
import java.nio.file.Path;
import java.util.List;

public class App {
    static final Path CAMINHO = Path.of(System.getProperty("user.home"), "tarefas.csv");

    public static void main(String[] args) throws IOException {
        if (args.length == 0) {
            uso();
            System.exit(1);
        }
        Arquivo arquivo = new Arquivo(CAMINHO);
        List<Tarefa> tarefas = arquivo.carregar();

        switch (args[0]) {
            case "add" -> adicionar(tarefas, args, arquivo);
            case "list" -> listar(tarefas);
            default -> {
                System.err.println("Comando desconhecido: " + args[0]);
                uso();
                System.exit(1);
            }
        }
    }

    static void adicionar(List<Tarefa> tarefas, String[] args, Arquivo arquivo) throws IOException {
        if (args.length < 2) {
            System.err.println("Uso: tarefas add \"descrição\"");
            System.exit(1);
        }
        int id = tarefas.size() + 1;
        Tarefa nova = new Tarefa(id, args[1], false);
        tarefas.add(nova);
        arquivo.salvar(tarefas);
        System.out.println("Adicionada: " + nova);
    }

    static void listar(List<Tarefa> tarefas) {
        for (Tarefa t : tarefas) {
            System.out.println(t);
        }
        System.out.println(tarefas.size() + " tarefa(s).");
    }

    static void uso() {
        System.err.println("Uso: tarefas add \"descrição\" | list");
    }
}
TrechoO que faz
System.getProperty("user.home")A pasta pessoal de quem roda o programa (/home/ana). O arquivo fica sempre em ~/tarefas.csv, não importa de que pasta o comando foi chamado.
Path.of(pasta, "tarefas.csv")Junta os pedaços do caminho com a barra certa para o sistema.
case "add" -> adicionar(tarefas, args, arquivo);Cada subcomando vira um método. O main só carrega, escolhe e chama.
int id = tarefas.size() + 1;Numera as tarefas 1, 2, 3… Funciona porque não existe remoção (veja os desafios).
$ java -cp target/classes br.com.ana.tarefas.App add "Comprar pão"
Adicionada: [ ] 1. Comprar pão
$ java -cp target/classes br.com.ana.tarefas.App add "Estudar streams; capítulo 3"
Adicionada: [ ] 2. Estudar streams; capítulo 3
$ java -cp target/classes br.com.ana.tarefas.App list
[ ] 1. Comprar pão
[ ] 2. Estudar streams; capítulo 3
2 tarefa(s).                              « agora o pão ficou
$ cat ~/tarefas.csv
1;false;Comprar pão
2;false;Estudar streams; capítulo 3
$ java -cp target/classes br.com.ana.tarefas.App add
Uso: tarefas add "descrição"

Missão (etapa 3): este programa lê duas linhas de CSV, mas a segunda tarefa aparece cortada: [ ] 2. Estudar streams. Conserte o split para a descrição chegar inteira, com o ; capítulo 3.

import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> linhas = List.of(
                "1;true;Comprar pão",
                "2;false;Estudar streams; capítulo 3");
        for (String linha : linhas) {
            System.out.println(deLinha(linha));
        }
    }

    static Tarefa deLinha(String linha) {
        String[] partes = linha.split(";");
        return new Tarefa(Integer.parseInt(partes[0]), partes[2], Boolean.parseBoolean(partes[1]));
    }
}

record Tarefa(int id, String descricao, boolean feita) {
    @Override
    public String toString() {
        return (feita ? "[x] " : "[ ] ") + id + ". " + descricao;
    }
}
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> linhas = List.of(
                "1;true;Comprar pão",
                "2;false;Estudar streams; capítulo 3");
        for (String linha : linhas) {
            System.out.println(deLinha(linha));
        }
    }

    static Tarefa deLinha(String linha) {
        String[] partes = linha.split(";", 3);
        return new Tarefa(Integer.parseInt(partes[0]), partes[2], Boolean.parseBoolean(partes[1]));
    }
}

record Tarefa(int id, String descricao, boolean feita) {
    @Override
    public String toString() {
        return (feita ? "[x] " : "[ ] ") + id + ". " + descricao;
    }
}

Etapa 4 · Concluir e filtrar

Duas novidades: o subcomando done e os filtros do list (--pendentes e --feitas), feitos com streams. Primeiro, o record ganha um método que devolve uma cópia "concluída" (records são imutáveis, então não dá para mudar o feita da tarefa existente):

    public Tarefa concluida() {
        return new Tarefa(id, descricao, true);
    }

O listar com filtros:

    static void listar(List<Tarefa> tarefas, String[] args) {
        String filtro = args.length > 1 ? args[1] : "--todas";
        List<Tarefa> escolhidas = switch (filtro) {
            case "--pendentes" -> tarefas.stream().filter(t -> !t.feita()).toList();
            case "--feitas" -> tarefas.stream().filter(Tarefa::feita).toList();
            default -> tarefas;
        };
        escolhidas.forEach(System.out::println);
        long pendentes = tarefas.stream().filter(t -> !t.feita()).count();
        System.out.println(tarefas.size() + " tarefa(s), " + pendentes + " pendente(s).");
    }
TrechoO que faz
tarefas.stream()Abre um "fluxo" com os itens da lista, um atrás do outro, para passar por etapas.
.filter(t -> !t.feita())Deixa passar só os itens em que a condição é verdadeira. t -> !t.feita() é uma lambda: "dada uma tarefa t, ela não está feita?".
.filter(Tarefa::feita)O mesmo, ao contrário, com uma referência de método: "chame feita() em cada tarefa".
.toList()Junta o que sobrou numa lista nova (que não pode ser alterada). A lista original não muda.
.count()Conta quantos itens passaram. Devolve long.
escolhidas.forEach(System.out::println)Para cada tarefa, chama System.out.println.

E o concluir, que acha a tarefa pelo número e põe a cópia concluída no lugar com set:

    static void concluir(List<Tarefa> tarefas, String[] args, Arquivo arquivo) throws IOException {
        if (args.length < 2) {
            System.err.println("Uso: tarefas done NÚMERO");
            System.exit(1);
        }
        int id;
        try {
            id = Integer.parseInt(args[1]);
        } catch (NumberFormatException e) {
            System.err.println("O número da tarefa deve ser um número: " + args[1]);
            System.exit(1);
            return;
        }
        for (int i = 0; i < tarefas.size(); i++) {
            if (tarefas.get(i).id() == id) {
                Tarefa feita = tarefas.get(i).concluida();
                tarefas.set(i, feita);
                arquivo.salvar(tarefas);
                System.out.println("Concluída: " + feita);
                return;
            }
        }
        System.err.println("Não existe a tarefa " + id + ".");
        System.exit(1);
    }

Repare no return; logo depois de System.exit(1) dentro do catch. Ele nunca roda (o exit encerra o programa antes), mas o compilador não sabe disso: sem ele, reclamaria que id pode não ter valor na linha do for (variable id might not have been initialized). E no main, o switch ganha case "done" -> concluir(tarefas, args, arquivo); e o listar passa a receber args.

$ java -cp target/classes br.com.ana.tarefas.App add "Pagar a conta de luz"
Adicionada: [ ] 3. Pagar a conta de luz
$ java -cp target/classes br.com.ana.tarefas.App done 1
Concluída: [x] 1. Comprar pão
$ java -cp target/classes br.com.ana.tarefas.App list
[x] 1. Comprar pão
[ ] 2. Estudar streams; capítulo 3
[ ] 3. Pagar a conta de luz
3 tarefa(s), 2 pendente(s).
$ java -cp target/classes br.com.ana.tarefas.App list --pendentes
[ ] 2. Estudar streams; capítulo 3
[ ] 3. Pagar a conta de luz
3 tarefa(s), 2 pendente(s).
$ java -cp target/classes br.com.ana.tarefas.App list --feitas
[x] 1. Comprar pão
3 tarefa(s), 2 pendente(s).
$ cat ~/tarefas.csv
1;true;Comprar pão
2;false;Estudar streams; capítulo 3
3;false;Pagar a conta de luz
$ java -cp target/classes br.com.ana.tarefas.App done 9
Não existe a tarefa 9.
$ java -cp target/classes br.com.ana.tarefas.App done abc
O número da tarefa deve ser um número: abc
Quando dá errado: done abc sem o try/catch

Na primeira versão do concluir, a Ana escreveu só int id = Integer.parseInt(args[1]);:

$ java -cp target/classes br.com.ana.tarefas.App done abc
Exception in thread "main" java.lang.NumberFormatException: For input string: "abc"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:565)
	at java.base/java.lang.Integer.parseInt(Integer.java:662)
	at br.com.ana.tarefas.App.concluir(App.java:59)
	at br.com.ana.tarefas.App.main(App.java:21)

"Erro de formato de número para a entrada "abc"." Para quem usa a ferramenta, um stack trace assusta e não ajuda. O try/catch troca isso por uma frase clara e o código de saída 1.

Missão (etapa 4): mostre só as tarefas pendentes, com stream().filter(…), e depois 2 pendente(s)., com count(). A tarefa feita ([x]) não pode aparecer.

import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Tarefa> tarefas = List.of(
                new Tarefa(1, "Comprar pão", true),
                new Tarefa(2, "Estudar streams", false),
                new Tarefa(3, "Pagar a conta de luz", false));

        // mostre só as tarefas pendentes (feita == false) usando stream().filter(...)
        // e depois a linha "2 pendente(s)." usando count()
    }
}

record Tarefa(int id, String descricao, boolean feita) {
    @Override
    public String toString() {
        return (feita ? "[x] " : "[ ] ") + id + ". " + descricao;
    }
}
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Tarefa> tarefas = List.of(
                new Tarefa(1, "Comprar pão", true),
                new Tarefa(2, "Estudar streams", false),
                new Tarefa(3, "Pagar a conta de luz", false));

        List<Tarefa> pendentes = tarefas.stream().filter(t -> !t.feita()).toList();
        pendentes.forEach(System.out::println);
        long quantas = tarefas.stream().filter(t -> !t.feita()).count();
        System.out.println(quantas + " pendente(s).");
    }
}

record Tarefa(int id, String descricao, boolean feita) {
    @Override
    public String toString() {
        return (feita ? "[x] " : "[ ] ") + id + ". " + descricao;
    }
}

Etapa 5 · O JAR executável

Digitar java -cp target/classes br.com.ana.tarefas.App toda vez é cansativo. Um JAR junta as classes num arquivo só, e um JAR executável já sabe qual é a classe principal. O mvn package gera um JAR, mas sem a classe principal:

$ mvn -q clean package
$ ls target
classes  generated-sources  maven-archiver  maven-status  tarefas-1.0.jar
$ java -jar target/tarefas-1.0.jar list
nenhum atributo de manifesto principal em target/tarefas-1.0.jar

"Nenhum atributo de manifesto principal": falta o Main-Class no arquivo de manifesto do JAR (a explicação completa está em Empacotar em JAR). Quem escreve o manifesto é o plugin maven-jar-plugin; basta configurá-lo no pom.xml, que fica assim:

<?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>br.com.ana</groupId>
    <artifactId>tarefas</artifactId>
    <version>1.0</version>

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

    <build>
        <finalName>tarefas</finalName>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.5.1</version>
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>br.com.ana.tarefas.App</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
<finalName>tarefas</finalName>
O nome do arquivo gerado: tarefas.jar, em vez de tarefas-1.0.jar.
maven-jar-plugin 3.5.1
O plugin que monta o JAR (o Maven já o usava; agora nós o configuramos).
<mainClass>br.com.ana.tarefas.App</mainClass>
Vira a linha Main-Class: br.com.ana.tarefas.App do manifesto.
$ mvn clean package
[INFO] Scanning for projects...
[INFO]
[INFO] -------------------------< br.com.ana:tarefas >-------------------------
[INFO] Building tarefas 1.0
[INFO]   from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO] Loaded 24119 auto-discovered prefixes for remote repository central (prefixes-central.txt)
[INFO]
[INFO] --- clean:3.5.0:clean (default-clean) @ tarefas ---
[INFO] Deleting /home/ana/java/tarefas/target
[INFO]
[INFO] --- resources:3.5.0:resources (default-resources) @ tarefas ---
[INFO] skip non existing resourceDirectory /home/ana/java/tarefas/src/main/resources
[INFO]
[INFO] --- compiler:3.16.0:compile (default-compile) @ tarefas ---
[INFO] Loaded 74 auto-discovered prefixes for remote repository apache.snapshots (prefixes-apache.snapshots.txt)
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 3 source files with javac [debug release 25] to target/classes
[INFO]
[INFO] --- resources:3.5.0:testResources (default-testResources) @ tarefas ---
[INFO] skip non existing resourceDirectory /home/ana/java/tarefas/src/test/resources
[INFO]
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ tarefas ---
[INFO] No sources to compile
[INFO]
[INFO] --- surefire:3.6.0:test (default-test) @ tarefas ---
[INFO] No tests to run.
[INFO]
[INFO] --- jar:3.5.1:jar (default-jar) @ tarefas ---
[INFO] Building jar: /home/ana/java/tarefas/target/tarefas.jar
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  3.003 s
[INFO] Finished at: 2026-10-16T19:02:43-03:00
[INFO] ------------------------------------------------------------------------
$ ls target
classes  generated-sources  maven-archiver  maven-status  tarefas.jar
$ java -jar target/tarefas.jar list
[x] 1. Comprar pão
[ ] 2. Estudar streams; capítulo 3
[ ] 3. Pagar a conta de luz
3 tarefa(s), 2 pendente(s).

O package passou por todas as fases (clean, compile, test, que aqui não tem testes, e jar) e gerou target/tarefas.jar. Esse arquivo é o programa inteiro: copie para qualquer computador com Java 25 e rode com java -jar tarefas.jar.

O último toque é o alias do Bash, que transforma o comando comprido em tarefas (a sessão completa está no começo da página). Para o alias valer sempre, coloque a linha alias tarefas='java -jar ~/java/tarefas/target/tarefas.jar' no seu ~/.bashrc.


O código completo

O pom.xml está na etapa 5 e o Arquivo.java na etapa 3. A versão final dos outros dois:

package br.com.ana.tarefas;

public record Tarefa(int id, String descricao, boolean feita) {

    public Tarefa concluida() {
        return new Tarefa(id, descricao, true);
    }

    public String paraLinha() {
        return id + ";" + feita + ";" + descricao;
    }

    public static Tarefa deLinha(String linha) {
        String[] partes = linha.split(";", 3);
        return new Tarefa(Integer.parseInt(partes[0]), partes[2], Boolean.parseBoolean(partes[1]));
    }

    @Override
    public String toString() {
        return (feita ? "[x] " : "[ ] ") + id + ". " + descricao;
    }
}
package br.com.ana.tarefas;

import java.io.IOException;
import java.nio.file.Path;
import java.util.List;

public class App {
    static final Path CAMINHO = Path.of(System.getProperty("user.home"), "tarefas.csv");

    public static void main(String[] args) throws IOException {
        if (args.length == 0) {
            uso();
            System.exit(1);
        }
        Arquivo arquivo = new Arquivo(CAMINHO);
        List<Tarefa> tarefas = arquivo.carregar();

        switch (args[0]) {
            case "add" -> adicionar(tarefas, args, arquivo);
            case "list" -> listar(tarefas, args);
            case "done" -> concluir(tarefas, args, arquivo);
            default -> {
                System.err.println("Comando desconhecido: " + args[0]);
                uso();
                System.exit(1);
            }
        }
    }

    static void adicionar(List<Tarefa> tarefas, String[] args, Arquivo arquivo) throws IOException {
        if (args.length < 2) {
            System.err.println("Uso: tarefas add \"descrição\"");
            System.exit(1);
        }
        int id = tarefas.size() + 1;
        Tarefa nova = new Tarefa(id, args[1], false);
        tarefas.add(nova);
        arquivo.salvar(tarefas);
        System.out.println("Adicionada: " + nova);
    }

    static void listar(List<Tarefa> tarefas, String[] args) {
        String filtro = args.length > 1 ? args[1] : "--todas";
        List<Tarefa> escolhidas = switch (filtro) {
            case "--pendentes" -> tarefas.stream().filter(t -> !t.feita()).toList();
            case "--feitas" -> tarefas.stream().filter(Tarefa::feita).toList();
            default -> tarefas;
        };
        escolhidas.forEach(System.out::println);
        long pendentes = tarefas.stream().filter(t -> !t.feita()).count();
        System.out.println(tarefas.size() + " tarefa(s), " + pendentes + " pendente(s).");
    }

    static void concluir(List<Tarefa> tarefas, String[] args, Arquivo arquivo) throws IOException {
        if (args.length < 2) {
            System.err.println("Uso: tarefas done NÚMERO");
            System.exit(1);
        }
        int id;
        try {
            id = Integer.parseInt(args[1]);
        } catch (NumberFormatException e) {
            System.err.println("O número da tarefa deve ser um número: " + args[1]);
            System.exit(1);
            return;
        }
        for (int i = 0; i < tarefas.size(); i++) {
            if (tarefas.get(i).id() == id) {
                Tarefa feita = tarefas.get(i).concluida();
                tarefas.set(i, feita);
                arquivo.salvar(tarefas);
                System.out.println("Concluída: " + feita);
                return;
            }
        }
        System.err.println("Não existe a tarefa " + id + ".");
        System.exit(1);
    }

    static void uso() {
        System.err.println("Uso: tarefas add \"descrição\" | list [--pendentes|--feitas] | done NÚMERO");
    }
}

Desafios extras

  1. Remover. Um subcomando rm NÚMERO. Cuidado: com remoção, tarefas.size() + 1 pode repetir um número. Troque por tarefas.stream().mapToInt(Tarefa::id).max().orElse(0) + 1 ("o maior id + 1").
  2. Buscar. tarefas find pão mostra as tarefas cuja descrição contém o texto (um filter com contains).
  3. Prioridade. Um campo prioridade (um enum ALTA, MEDIA, BAIXA) no record e no CSV, e o list ordenado por ela com sorted(Comparator.comparing(Tarefa::prioridade)).
  4. Testes. Acrescente o JUnit (como no projeto do banco) e teste paraLinha e deLinha: uma tarefa que vira linha e volta deve ser igual à original.
  5. Arquivo antigo. O que acontece se o CSV tiver uma linha estragada? Trate como no projeto da agenda.

Missão final: a missão roda java Main.java done abc e o programa estoura com NumberFormatException. Trate com try/catch: mostre O número da tarefa deve ser um número: abc em System.err e saia com o código 1.

public class Main {
    public static void main(String[] args) {
        if (args.length < 2 || !args[0].equals("done")) {
            System.err.println("Uso: tarefas done NÚMERO");
            System.exit(1);
        }
        int id = Integer.parseInt(args[1]);
        System.out.println("Concluir a tarefa " + id);
    }
}
public class Main {
    public static void main(String[] args) {
        if (args.length < 2 || !args[0].equals("done")) {
            System.err.println("Uso: tarefas done NÚMERO");
            System.exit(1);
        }
        int id;
        try {
            id = Integer.parseInt(args[1]);
        } catch (NumberFormatException e) {
            System.err.println("O número da tarefa deve ser um número: " + args[1]);
            System.exit(1);
            return;
        }
        System.out.println("Concluir a tarefa " + id);
    }
}

Exercícios

1. Quantos itens tem args em java -jar tarefas.jar add Comprar pão (sem aspas)?

2. Por que as mensagens de erro usam System.err?

3. O que "2;false;Ler; e anotar".split(";", 3) devolve?

4. Sem o maven-jar-plugin configurado, java -jar target/tarefas-1.0.jar diz…

Complete o filtro que deixa passar só as tarefas pendentes:

tarefas.stream().(t -> !t.feita()).toList()

Resumo

  • args traz as palavras da linha de comando; o Bash corta nos espaços e as aspas juntam. Confira args.length antes de ler.
  • Subcomandos: switch (args[0]), um método por comando, erros em System.err e System.exit(1).
  • CSV simples: paraLinha/deLinha, com o campo de texto livre por último e split(";", 3).
  • Streams: stream().filter(…).toList() para escolher, count() para contar.
  • maven-jar-plugin com mainClass + mvn package = JAR executável; java -jar tarefas.jar list.

Você terminou os projetos do guia. Para consultar algo rápido, use a Referência; para se testar, faça o Quiz.