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.
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.
Command value. → 2. Load the saved tasks. → 3. Apply the command via an exhaustive switch expression. → 4. Save the result and print a message.
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.
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());
}
}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.
id field on each Task, as this program does.tasks.stream().mapToInt(Task::id).max().orElse(0) + 1 from the current task list.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.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.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.
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
What it expects
java CliTaskManager add "buy milk"
java CliTaskManager complete 1
java CliTaskManager listWhat it returns
Added task #1.
#1 [x] buy milk
#2 [ ] walk dog6 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.
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.
$ 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: bogustasks.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 CliTaskManager complete 1, not a task’s text.// 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.' }
}
}
- Add a "clear" subcommand. A sixth permitted
Commandtype. (Teaches: extending a sealed hierarchy and updating every switch that covers it.) - Add priorities. Give each task a
Priorityenum and sortlistby it. (Teaches: enums with a natural ordering, viaComparator.) - Support editing a task’s text. A new
Edit(int id, String text)command. (Teaches: extending a record-based sealed type.)
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.