1 The Problem
We want a to-do list you can actually use: add tasks, see them numbered, remove the ones you finish, and — crucially — have them saved to a file so they survive after you close the program. It teaches lists, a menu loop, and saving data to disk.
2 How to Think About It
Every run of the program does the same three things in order: load whatever is saved, do one command, save the result back — the file is the only thing that survives between runs.
3 The Build — explained part by part
Here is the complete to-do list. Persistence is a hand-rolled done|text line format — deliberately simple rather than reaching for a JSON library, so the whole format fits in two small methods.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* To-Do List (CLI): add, list, and mark tasks done, persisted to a plain-text
* file so the list survives between runs.
*/
public class TodoList {
record Task(boolean done, String text) {
String toLine() {
return (done ? "1" : "0") + "|" + text;
}
static Task fromLine(String line) {
String[] parts = line.split("\\|", 2);
return new Task(parts[0].equals("1"), parts[1]);
}
}
static List<Task> load(Path file) throws IOException {
if (!Files.exists(file)) return new ArrayList<>();
List<Task> tasks = new ArrayList<>();
for (String line : Files.readAllLines(file)) {
if (!line.isBlank()) tasks.add(Task.fromLine(line));
}
return tasks;
}
static void save(Path file, List<Task> tasks) throws IOException {
List<String> lines = tasks.stream().map(Task::toLine).toList();
Files.write(file, lines);
}
static List<Task> add(List<Task> tasks, String text) {
List<Task> copy = new ArrayList<>(tasks);
copy.add(new Task(false, text));
return copy;
}
/** Returns false if index is out of range, without throwing. */
static boolean markDone(List<Task> tasks, int oneBasedIndex) {
int i = oneBasedIndex - 1;
if (i < 0 || i >= tasks.size()) return false;
tasks.set(i, new Task(true, tasks.get(i).text()));
return true;
}
public static void main(String[] args) throws IOException {
Path file = Path.of("todos.txt");
List<Task> tasks = load(file);
if (args.length == 0) {
System.out.println("Usage: add <text> | list | done <n>");
return;
}
switch (args[0]) {
case "add" -> {
String text = String.join(" ", Arrays.copyOfRange(args, 1, args.length));
tasks = add(tasks, text);
save(file, tasks);
System.out.println("Added.");
}
case "list" -> {
for (int i = 0; i < tasks.size(); i++) {
Task t = tasks.get(i);
String mark = t.done() ? "x" : " ";
System.out.printf("%d. [%s] %s%n", i + 1, mark, t.text());
}
}
case "done" -> {
boolean ok = markDone(tasks, Integer.parseInt(args[1]));
save(file, tasks);
System.out.println(ok ? "Marked done." : "No such task.");
}
default -> System.out.println("Unknown command.");
}
}
}
markDone replaces it in the list with a new Task that has done=true, which is why records suit this shape well — the “change” is really a replacement.toLine() / fromLine() — a deliberately simple
"1|buy milk"-style format: the first character before the pipe is 1 or 0 for done/not-done, everything after the pipe is the task text. No external JSON library needed for something this small, though a record like this is also exactly the shape libraries like Jackson serialize automatically in bigger projects.Files.readAllLines(file) / Files.write(file, lines) — NIO’s line-oriented helpers: read the whole file as a
List<String>, or write a list of strings back as one line each, both in a single call with no manual BufferedWriter setup.markDone returns a boolean instead of throwing — an out-of-range task number is an ordinary, expected outcome (the user typed a number that does not exist), not an exceptional one, so the method reports it as a return value the caller must check rather than an exception the caller might forget to catch.
| without a limit — line.split("\\|") would break if the task text itself ever contained a pipe character, silently dropping part of it.2 to split, as fromLine does, so only the first pipe splits the line.int i = oneBasedIndex - 1; as markDone does, and bounds-check before using it.4 Test & Prove Each Part
We test the record’s round-trip, the list operations, and one real file write/read — not just in-memory logic.
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class TodoListTest {
@Test
public void taskRoundTripsThroughALine() {
TodoList.Task t = new TodoList.Task(true, "buy milk");
TodoList.Task back = TodoList.Task.fromLine(t.toLine());
assertEquals(t, back);
}
@Test
public void addAppendsAnUndoneTask() {
List<TodoList.Task> tasks = TodoList.add(List.of(), "walk dog");
assertEquals(1, tasks.size());
assertFalse(tasks.get(0).done());
}
@Test
public void markDoneFlipsTheRightTask() {
List<TodoList.Task> seed = List.of(
new TodoList.Task(false, "a"), new TodoList.Task(false, "b"));
var tasks = new ArrayList<>(seed);
boolean ok = TodoList.markDone(tasks, 2);
assertTrue(ok);
assertTrue(tasks.get(1).done());
assertFalse(tasks.get(0).done());
}
@Test
public void markDoneOutOfRangeReturnsFalseInsteadOfThrowing() {
var tasks = new ArrayList<>(List.of(new TodoList.Task(false, "a")));
assertFalse(TodoList.markDone(tasks, 5));
assertFalse(TodoList.markDone(tasks, 0));
}
@Test
public void saveThenLoadRoundTripsThroughARealFile() throws Exception {
Path tmp = Files.createTempFile("todos", ".txt");
try {
List<TodoList.Task> tasks = List.of(
new TodoList.Task(true, "one"), new TodoList.Task(false, "two"));
TodoList.save(tmp, tasks);
List<TodoList.Task> loaded = TodoList.load(tmp);
assertEquals(tasks, loaded);
} finally {
Files.deleteIfExists(tmp);
}
}
}
Compile and run with javac -cp junit-4.13.2.jar and hamcrest-core-1.3.jar TodoList.java TodoListTest.java then java -cp .:junit-4.13.2.jar:hamcrest-core-1.3.jar org.junit.runner.JUnitCore TodoListTest. The last test uses Files.createTempFile to write and read back a real file on disk rather than mocking the filesystem — a genuine round-trip test, not just an in-memory one, and it cleans the temp file up in a finally block afterward.
5 The Interface
What it expects
java TodoList add "buy milk"
java TodoList list
java TodoList done 1What it returns
Added.
1. [x] buy milk
2. [ ] walk dog6 Run It & Automate It
Save the code as TodoList.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 TodoList.java && java TodoList add "buy milk"Each command is a separate run of the program — that is what makes persistence 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 TodoList add "buy milk"
Added.
$ java TodoList add "walk dog"
Added.
$ java TodoList done 1
Marked done.
$ java TodoList list
1. [x] buy milk
2. [ ] walk dogadd actually calls save after appending, and that both commands use the same file path (todos.txt, in whatever folder you run java from).done command expects a second argument, the task number — e.g. java TodoList done 1, not just java TodoList done.// 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 TodoListTest'
}
}
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
- Add a delete command. Remove a task by number. (Teaches: removing from a list by index safely.)
- Switch to JSON. Use a small JSON library instead of the pipe-delimited format. (Teaches: what a serialization library actually automates for you.)
- Add due dates. Extend the record with a
LocalDatefield. (Teaches: records with more than two fields, and date parsing.)
Files helpers for simple persistence, and returning a boolean for an expected failure instead of throwing for it. Related: Records, IO and NIO.