← thecodex.expert · The Codex Family of Knowledge
Tier 2 · Intermediate · Java Project

CLI Task Manager

A persistent task manager driven by subcommands (add, list, complete, remove). Teaches a sealed interface for subcommands, exhaustive switch expressions, and permanent, never-reused task IDs.

🧠 Teaches how to think spoonfed, every age Last verified:

1 The Problem

We want a real command-line tool: tasks add "Buy milk", tasks list, tasks done 2 — commands and arguments, just like git or npm. It teaches argparse, the proper way to build CLI tools that feel professional, not like a toy menu.

Where this shows up: every developer tool — git, docker, npm, pip. Building proper CLIs with subcommands and arguments is a core skill for automation, dev tools, and scripts others will use.

2 How to Think About It

Every subcommand is a different shape of instruction. Model that directly: parse the command line into one of a fixed set of command types, then let the compiler prove every type is handled.

The plan — in plain English
1. Parse the command line into a sealed Command value. → 2. Load the saved tasks. → 3. Apply the command via an exhaustive switch expression. → 4. Save the result and print a message.

add

list

done

Command line input

argparse parses it

Which subcommand?

Add task

Show tasks

Mark task done

Save to file

3 The Build — explained part by part

Here is the complete manager. A sealed interface with a fixed set of permits is what lets the switch below skip a default case entirely — the compiler already knows there is nothing else it could be.

JavaCliTaskManager.java
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

/**
 * CLI Task Manager: a persistent task list driven by subcommands, using a
 * sealed interface so the compiler can prove every subcommand is handled.
 */
public class CliTaskManager {

    record Task(int id, boolean done, String text) {}

    /** The saved state: the tasks, plus the id the next "add" should use. */
    record State(int nextId, List<Task> tasks) {}

    /** A parsed command line. permits closes the hierarchy so switch over it can be exhaustive. */
    sealed interface Command permits Add, List_, Complete, Remove, Unknown {}
    record Add(String text) implements Command {}
    record List_() implements Command {}
    record Complete(int id) implements Command {}
    record Remove(int id) implements Command {}
    record Unknown(String raw) implements Command {}

    static Command parse(String[] args) {
        if (args.length == 0) return new Unknown("");
        return switch (args[0]) {
            case "add" -> {
                String text = String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length));
                yield new Add(text);
            }
            case "list" -> new List_();
            case "complete" -> args.length > 1
                    ? new Complete(Integer.parseInt(args[1])) : new Unknown("complete");
            case "remove" -> args.length > 1
                    ? new Remove(Integer.parseInt(args[1])) : new Unknown("remove");
            default -> new Unknown(args[0]);
        };
    }

    /**
     * Reads the saved counter from the file's first line, so an id is never reused even
     * after the task holding it is removed -- only the list's current max would miss that.
     */
    static State load(Path file) throws Exception {
        if (!Files.exists(file)) return new State(1, new ArrayList<>());
        List<String> lines = Files.readAllLines(file).stream().filter(l -> !l.isBlank()).toList();
        if (lines.isEmpty()) return new State(1, new ArrayList<>());
        List<Task> tasks = new ArrayList<>();
        for (String line : lines.subList(1, lines.size())) {
            String[] parts = line.split("\\|", 3);
            tasks.add(new Task(Integer.parseInt(parts[0]), parts[1].equals("1"), parts[2]));
        }
        return new State(Integer.parseInt(lines.get(0)), tasks);
    }

    static void save(Path file, State state) throws Exception {
        List<String> lines = new ArrayList<>();
        lines.add(String.valueOf(state.nextId()));
        for (Task t : state.tasks()) {
            lines.add(t.id() + "|" + (t.done() ? "1" : "0") + "|" + t.text());
        }
        Files.write(file, lines);
    }

    /** Applying one command: the updated state, plus a message to print. */
    record Result(State state, String message) {}

    /** Applies [cmd] to [state] -- a pure function, directly testable with no file I/O involved. */
    static Result apply(Command cmd, State state) {
        List<Task> tasks = state.tasks();
        return switch (cmd) {
            case Add a -> {
                int id = state.nextId();
                List<Task> updated = new ArrayList<>(tasks);
                updated.add(new Task(id, false, a.text()));
                yield new Result(new State(id + 1, updated), "Added task #" + id + ".");
            }
            case List_ ignored -> {
                StringBuilder sb = new StringBuilder();
                for (Task t : tasks) {
                    sb.append(String.format("#%d [%s] %s%n", t.id(), t.done() ? "x" : " ", t.text()));
                }
                yield new Result(state, sb.toString().stripTrailing());
            }
            case Complete c -> {
                List<Task> updated = tasks.stream()
                        .map(t -> t.id() == c.id() ? new Task(t.id(), true, t.text()) : t)
                        .toList();
                boolean found = tasks.stream().anyMatch(t -> t.id() == c.id());
                String msg = found ? "Completed #" + c.id() + "." : "No task #" + c.id() + ".";
                yield new Result(new State(state.nextId(), updated), msg);
            }
            case Remove r -> {
                List<Task> updated = tasks.stream().filter(t -> t.id() != r.id()).toList();
                boolean found = updated.size() != tasks.size();
                String msg = found ? "Removed #" + r.id() + "." : "No task #" + r.id() + ".";
                yield new Result(new State(state.nextId(), updated), msg);
            }
            case Unknown u -> new Result(state, "Unknown command: " + u.raw());
        };
    }

    public static void main(String[] args) throws Exception {
        Path file = Path.of("tasks.db");
        State state = load(file);
        Result result = apply(parse(args), state);
        save(file, result.state());
        System.out.println(result.message());
    }
}
⚠ No in-browser playground here
Java compiles to JVM bytecode and needs a real JDK to run, so unlike the Python version of this project there is no editor above you can run in the browser. Copy the code below and run it on your own machine — it takes seconds once a JDK is installed.
What each part does — in plain words
sealed interface Command permits Add, List_, Complete, Remove, Unknown — from the course’s Sealed Classes lesson: this closes the hierarchy to exactly five possibilities. Unknown plays the role a default case would in an ordinary switch, but as a real, named type the compiler can reason about.

switch (cmd) { case Add a -> ...; case Unknown u -> ...; } with no default arm — this is pattern matching in switch from the course, combined with the sealed hierarchy: the compiler verifies every permitted type is covered and would refuse to compile if a new command type were added and forgotten here.

record State(int nextId, List<Task> tasks) — the saved counter travels alongside the tasks, not recomputed from them. That distinction matters: an earlier version of this program computed nextId as the current list's highest id plus one, which quietly reused an id once the task holding it was removed — see “If it breaks” below for the real bug this design avoids, caught by this page's own test suite while it was being verified.

record Result(State state, String message) — apply returns both the updated state (counter included) and a human message in one value, keeping the pure decision logic completely separate from main’s file I/O and printing.
Common mistakes — and how to avoid them
✗ Using the task’s position in the list as its ID — removing an earlier task would silently renumber every task after it.
✓ Store a permanent id field on each Task, as this program does.
✗ Computing the next id as tasks.stream().mapToInt(Task::id).max().orElse(0) + 1 from the current task list.
✓ This is a real bug, caught by this page's own test suite while building the Kotlin version of this same project: remove the task with the highest id, and the next add reuses that id, silently breaking the “ids are never reused” guarantee. The fix is to persist the next id as its own piece of state (State.nextId, saved as the file’s first line) rather than recomputing it from whatever tasks happen to remain — see the regression test below that specifically guards against this.
✗ Adding a default case to the switch “just in case” — that quietly defeats the whole point of a sealed interface, which is that the compiler proves every case is handled.
✓ Leave the switch without a default and let a genuinely unhandled command be a compile error, not a silent runtime fallback.

4 Test & Prove Each Part

We test parsing, ID assignment, and every command’s effect on the task list directly, plus one real file round-trip.

Every subcommand string parses to the right Command type
An id is never reused, even after the task holding it is removed
A fresh file with no saved state starts counting at 1
Completing an unknown ID reports it without changing the task list
Removing a task drops exactly one, leaving the others untouched
Saving to a real temp file and loading it back returns the same tasks
JavaCliTaskManagerTest.java
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

public class CliTaskManagerTest {

    @Test
    public void parseRecognizesEveryCommand() {
        assertTrue(CliTaskManager.parse(new String[]{"add", "milk"}) instanceof CliTaskManager.Add);
        assertTrue(CliTaskManager.parse(new String[]{"list"}) instanceof CliTaskManager.List_);
        var complete = CliTaskManager.parse(new String[]{"complete", "3"});
        var remove = CliTaskManager.parse(new String[]{"remove", "3"});
        assertTrue(complete instanceof CliTaskManager.Complete);
        assertTrue(remove instanceof CliTaskManager.Remove);
        assertTrue(CliTaskManager.parse(new String[]{"nope"}) instanceof CliTaskManager.Unknown);
    }

    @Test
    public void addAppliesAndAssignsTheNextId() {
        var result = CliTaskManager.apply(new CliTaskManager.Add("milk"), new CliTaskManager.State(1, List.of()));
        assertEquals(1, result.state().tasks().size());
        assertEquals(1, result.state().tasks().get(0).id());
        assertTrue(result.message().contains("Added task #1"));
    }

    @Test
    public void idIsNeverReusedAfterTheTaskHoldingItIsRemoved() {
        // This is the regression test for a real bug: an earlier version computed the
        // next id as one more than the CURRENT list's highest id. Remove the task
        // holding the highest id, and that version would hand the next "add" the same
        // id right back -- exactly what this test guards against.
        var afterAdd = CliTaskManager.apply(new CliTaskManager.Add("a"), new CliTaskManager.State(1, List.of())).state();
        var afterRemove = CliTaskManager.apply(new CliTaskManager.Remove(1), afterAdd).state();
        var afterSecondAdd = CliTaskManager.apply(new CliTaskManager.Add("b"), afterRemove).state();
        assertEquals(1, afterSecondAdd.tasks().size());
        assertEquals(2, afterSecondAdd.tasks().get(0).id());
    }

    @Test
    public void completeMarksOnlyTheMatchingTask() {
        var state = new CliTaskManager.State(3, List.of(
                new CliTaskManager.Task(1, false, "a"), new CliTaskManager.Task(2, false, "b")));
        var result = CliTaskManager.apply(new CliTaskManager.Complete(2), state);
        assertTrue(result.state().tasks().get(1).done());
        assertTrue(!result.state().tasks().get(0).done());
    }

    @Test
    public void completeUnknownIdReportsNotFoundWithoutChangingTasks() {
        var state = new CliTaskManager.State(2, List.of(new CliTaskManager.Task(1, false, "a")));
        var result = CliTaskManager.apply(new CliTaskManager.Complete(99), state);
        assertEquals(state.tasks(), result.state().tasks());
        assertTrue(result.message().contains("No task #99"));
    }

    @Test
    public void removeDropsExactlyOneTask() {
        var state = new CliTaskManager.State(3, List.of(
                new CliTaskManager.Task(1, false, "a"), new CliTaskManager.Task(2, false, "b")));
        var result = CliTaskManager.apply(new CliTaskManager.Remove(1), state);
        assertEquals(1, result.state().tasks().size());
        assertEquals(2, result.state().tasks().get(0).id());
    }

    @Test
    public void saveThenLoadRoundTripsThroughARealFile() throws Exception {
        var tmp = java.nio.file.Files.createTempFile("tasks", ".db");
        try {
            var state = new CliTaskManager.State(3, List.of(
                    new CliTaskManager.Task(1, true, "one"), new CliTaskManager.Task(2, false, "two")));
            CliTaskManager.save(tmp, state);
            assertEquals(state, CliTaskManager.load(tmp));
        } finally {
            java.nio.file.Files.deleteIfExists(tmp);
        }
    }

    @Test
    public void aFreshFileStartsCountingAtOne() throws Exception {
        var state = CliTaskManager.load(java.nio.file.Path.of("/tmp/__java_cli_task_manager_test_missing__.db"));
        assertEquals(1, state.nextId());
        assertEquals(List.of(), state.tasks());
    }
}

Compile and run with javac -cp junit-4.13.2.jar and hamcrest-core-1.3.jar CliTaskManager.java CliTaskManagerTest.java then java -cp .:junit-4.13.2.jar:hamcrest-core-1.3.jar org.junit.runner.JUnitCore CliTaskManagerTest. Notice apply is tested directly against plain List values — none of these 7 tests touch the filesystem except the last one, which deliberately does, via Files.createTempFile.

5 The Interface

INPUTINPUTa subcommand and arguments
What it expects
java CliTaskManager add "buy milk"
java CliTaskManager complete 1
java CliTaskManager list
OUTPUTOUTPUTa confirmation message or the task list
What it returns
Added task #1.
#1 [x] buy milk
#2 [ ] walk dog

6 Run It & Automate It

Save the code as CliTaskManager.java and compile it with javac — that turns your source into .class bytecode files, which java then runs on the JVM. No separate install step: any real JDK ships both tools.

Run it locally
javac CliTaskManager.java && java CliTaskManager add "buy milk"
Each command is a separate run — that is what makes the sealed Command hierarchy and persistence both necessary.

A CI tool like Jenkins runs the same compile-then-test steps automatically whenever the code changes — every line below has a plain explanation.

What you should see when it works
Terminala real run
$ java CliTaskManager add "buy milk"
Added task #1.
$ java CliTaskManager add "walk dog"
Added task #2.
$ java CliTaskManager complete 1
Completed #1.
$ java CliTaskManager list
#1 [x] buy milk
#2 [ ] walk dog
$ java CliTaskManager remove 2
Removed #2.
$ java CliTaskManager add "read book"
Added task #3.
$ java CliTaskManager list
#1 [x] buy milk
#3 [ ] read book
$ java CliTaskManager bogus
Unknown command: bogus
If it breaks — how to fix it
🚨 A removed task’s id gets reused by the next add.
That would mean tasks.db’s first line (the saved counter) got out of sync with the tasks below it — check that save(file, result.state()) runs after every command, writing both the counter and the tasks together.
🚨 java.lang.NumberFormatException on "complete" or "remove"
These subcommands expect a numeric ID as the second argument, e.g. java CliTaskManager complete 1, not a task’s text.
GroovyJenkinsfile
// Jenkinsfile — runs the tests automatically every time the code changes.
pipeline {
    agent any                          // run on any available machine
    environment {
        CP = 'junit-4.13.2.jar:hamcrest-core-1.3.jar'   // JUnit + its one dependency
    }

    stages {
        stage('Get the code') {
            steps { checkout scm }     // download the latest code
        }
        stage('Set up JDK') {
            steps {
                sh 'java -version'           // confirm a JDK is installed
                sh 'javac -cp "$CP" *.java'   // compile the program and its tests together
            }
        }
        stage('Run the tests') {
            steps {
                sh 'java -cp ".:$CP" org.junit.runner.JUnitCore CliTaskManagerTest'
            }
        }
    }

    post {
        success { echo 'All tests passed.' }
        failure { echo 'A test failed — look above.' }
    }
}
🎯 Try this next — make it yours
  1. Add a "clear" subcommand. A sixth permitted Command type. (Teaches: extending a sealed hierarchy and updating every switch that covers it.)
  2. Add priorities. Give each task a Priority enum and sort list by it. (Teaches: enums with a natural ordering, via Comparator.)
  3. Support editing a task’s text. A new Edit(int id, String text) command. (Teaches: extending a record-based sealed type.)
What you learned
You learned a sealed interface closing a set of possibilities so an exhaustive switch needs no default case, the permanent-ID pattern that survives deletions safely, and separating a pure decision function from the I/O that surrounds it. Related: Sealed Classes, Pattern Matching.