← thecodex.expert · The Codex Family of Knowledge
Tier 1 · Beginner · Java Project

To-Do List (CLI)

A command-line to-do list that remembers tasks between runs. Teaches a record for the task shape, and hand-rolled persistence with the modern Files API.

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

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.

Where this shows up: every app that stores your stuff — notes, reminders, shopping lists, saved games, settings. The pattern of “keep a list in memory, save it to a file, load it back next time” is the simplest form of a database.

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.

The plan — in plain English
1. Load existing tasks from a text file, if it exists. → 2. Apply one command: add, list, or done. → 3. Save the updated list back to the same file. → 4. Next run starts from step 1, picking up exactly where the last one left off.

Add

View

Remove

Quit

Load tasks from file

Show menu

Choice?

Add task

Show tasks

Remove task

Stop

Save to file

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.

JavaTodoList.java
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.");
        }
    }
}
⚠ 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
record Task(boolean done, String text) — an immutable data carrier from the course’s Records lesson. Marking a task done does not mutate it in place; instead 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.
Common mistakes — and how to avoid them
✗ Splitting the saved line on | without a limit — line.split("\\|") would break if the task text itself ever contained a pipe character, silently dropping part of it.
✓ Pass a limit of 2 to split, as fromLine does, so only the first pipe splits the line.
✗ Using a 0-based index directly from user input — a person typing “task 1” means the first task, but Java lists are 0-indexed internally.
✓ Convert once, clearly, at the boundary: 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.

A task survives being turned into a line and back unchanged
Adding a task appends it as not-done
Marking a task done flips only that task, leaving the others alone
An out-of-range task number returns false instead of throwing
Saving to a real temp file and loading it back returns the same tasks
JavaTodoListTest.java
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

INPUTINPUTa command and arguments
What it expects
java TodoList add "buy milk"
java TodoList list
java TodoList done 1
OUTPUTOUTPUTconfirmation or the task list
What it returns
Added.
1. [x] buy milk
2. [ ] walk dog

6 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.

Run it locally
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.

What you should see when it works
Terminala real run
$ 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 dog
If it breaks — how to fix it
🚨 The list is empty every time I run "list".
Check that add actually calls save after appending, and that both commands use the same file path (todos.txt, in whatever folder you run java from).
🚨 java.lang.ArrayIndexOutOfBoundsException on "done"
The done command expects a second argument, the task number — e.g. java TodoList done 1, not just java TodoList done.
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 TodoListTest'
            }
        }
    }

    post {
        success { echo 'All tests passed.' }
        failure { echo 'A test failed — look above.' }
    }
}
🎯 Try this next — make it yours
  1. Add a delete command. Remove a task by number. (Teaches: removing from a list by index safely.)
  2. Switch to JSON. Use a small JSON library instead of the pipe-delimited format. (Teaches: what a serialization library actually automates for you.)
  3. Add due dates. Extend the record with a LocalDate field. (Teaches: records with more than two fields, and date parsing.)
What you learned
You learned records as immutable data carriers where a “change” means building a replacement, NIO’s line-oriented Files helpers for simple persistence, and returning a boolean for an expected failure instead of throwing for it. Related: Records, IO and NIO.