Build a command-line task manager that survives process restarts. It supports add, list and done, assigns positive numeric identifiers, and stores tasks in a local UTF-8 file. The complete Java 21 implementation below has no external dependencies.
Work in a new folder: the program owns tasks.tsv in its working directory. Run one process at a time. This is a small local application, not a concurrent database. Its design gives you concrete places to apply records, collections, exceptions and integration tests.
Define the model and file contract
Each row contains an identifier, an open or done status, and a title, separated by tabs. Titles cannot be blank or contain tabs or line breaks. Rejecting those characters keeps this deliberately small format unambiguous; general CSV and JSON require proper parsers.
A missing file means an empty task list. A malformed row means failure, never an empty list that could overwrite existing data. Duplicate identifiers are invalid. Marking an already completed task is allowed and leaves it completed.
Save as Tasks.java:
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class Tasks {
record Task(int id, boolean done, String title) {
Task {
if (id <= 0 || title == null || title.isBlank()
|| title.contains("\t") || title.contains("\n")
|| title.contains("\r")) {
throw new IllegalArgumentException("invalid task");
}
title = title.strip();
}
}
static List<Task> load(Path file) throws IOException {
List<String> lines;
try {
lines = Files.readAllLines(file, StandardCharsets.UTF_8);
} catch (NoSuchFileException e) {
return new ArrayList<>();
}
List<Task> tasks = new ArrayList<>();
Set<Integer> ids = new HashSet<>();
for (int i = 0; i < lines.size(); i++) {
try {
String[] parts = lines.get(i).split("\t", -1);
if (parts.length != 3
|| !List.of("open", "done").contains(parts[1])) {
throw new IllegalArgumentException("invalid columns");
}
Task task = new Task(Integer.parseInt(parts[0]),
parts[1].equals("done"), parts[2]);
if (!ids.add(task.id())) {
throw new IllegalArgumentException("duplicate id");
}
tasks.add(task);
} catch (IllegalArgumentException e) {
throw new IOException("invalid row " + (i + 1), e);
}
}
return tasks;
}
static void save(Path file, List<Task> tasks) throws IOException {
Path target = file.toAbsolutePath();
Path temporary = Files.createTempFile(target.getParent(), "tasks-", ".tmp");
try {
List<String> lines = tasks.stream()
.map(task -> task.id() + "\t" + (task.done() ? "done" : "open")
+ "\t" + task.title()).toList();
Files.write(temporary, lines, StandardCharsets.UTF_8);
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE);
} finally {
Files.deleteIfExists(temporary);
}
}
static void run(String[] args) throws IOException {
if (!((args.length == 1 && args[0].equals("list"))
|| (args.length == 2 && List.of("add", "done").contains(args[0])))) {
throw new IllegalArgumentException("Usage: java Tasks list|add TITLE|done ID");
}
Path file = Path.of("tasks.tsv");
List<Task> tasks = load(file);
switch (args[0]) {
case "list" -> {
for (Task task : tasks) {
System.out.println(task.id() + (task.done() ? " [x] " : " [ ] ")
+ task.title());
}
return;
}
case "add" -> {
int largest = tasks.stream().mapToInt(Task::id).max().orElse(0);
tasks.add(new Task(Math.addExact(largest, 1), false, args[1]));
}
case "done" -> {
int id = Integer.parseInt(args[1]);
int index = -1;
for (int i = 0; i < tasks.size(); i++) {
if (tasks.get(i).id() == id) {
index = i;
break;
}
}
if (index == -1) {
throw new IllegalArgumentException("unknown id: " + id);
}
Task task = tasks.get(index);
tasks.set(index, new Task(task.id(), true, task.title()));
}
}
save(file, tasks);
System.out.println("Saved " + tasks.size() + " tasks.");
}
public static void main(String[] args) {
try {
run(args);
} catch (IllegalArgumentException | ArithmeticException e) {
System.err.println(e.getMessage());
System.exit(2);
} catch (IOException e) {
System.err.println("Storage error: " + e.getMessage());
System.exit(1);
}
}
}
Compile and try a complete workflow:
javac --release 21 -Xlint:all Tasks.java
java Tasks add "Read Java"
java Tasks add "Build CLI"
java Tasks done 1
java Tasks list
The mutations print Saved 1 tasks., then Saved 2 tasks. twice. The final command prints:
1 [x] Read Java
2 [ ] Build CLI
Each command starts a new JVM, proving the tasks survive process boundaries. Run list again from the same folder. The working directory determines which task file you open.
Explain persistence and its limits
Saving writes a separate temporary file in the destination directory, then requests an atomic move. Atomic means other observers do not see a partly moved file. If the filesystem cannot perform that operation, saving fails. Replacing an existing target under ATOMIC_MOVE is implementation-specific; a provider may reject it. There is no silent fallback to truncating the original.
Atomic movement does not guarantee durability after power loss or coordinate concurrent writers. The application also loads the whole small file into memory. These are explicit limits to revisit before storing large or shared datasets.
The identifier uses the largest existing value plus one, with overflow detection. Completing a task creates a replacement record rather than mutating its fields. Success is printed only after saving returns, so a failed write cannot report a successful update.
Exercise, acceptance checks and solution guidance
Test empty-list behavior, a Unicode title, a blank title, an unknown identifier and marking the same task done twice. Invalid arguments should exit with code two; malformed storage should exit with code one. Failed commands must leave the saved file unchanged.
In a temporary test directory, create a duplicate-id row and an invalid status, then attempt an add. Verify failure and compare file bytes before and after. Call save and load directly from a standalone test class to check round-trip equality. Always clean up test files.
Add a read-only stats command that prints open and completed counts. Extend argument validation, count records by done(), and return before save. Empty storage should produce two zero counts. Next, extract a storage interface and separate argument parsing from operations. You can then reuse the model behind the previous lesson's HTTP boundary while testing storage independently.
Official references
Previous: Command-Line Tools and HTTP APIs. Revisit Testing and Debugging when extending the project.

