Skip to main content

Project: CLI Task Manager

Time to put everything together. In this chapter you will build a command-line task manager that supports creating, listing, completing, and deleting tasks -- all persisted to a file. Then you will package it as a runnable JAR.

What we are building

A CLI tool called tasks with this interface:

java -jar tasks.jar add "Buy groceries"
java -jar tasks.jar list
java -jar tasks.jar done 1
java -jar tasks.jar delete 1

Features:

  • Add tasks with a description
  • List all tasks (showing ID, status, and description)
  • Complete a task by ID
  • Delete a task by ID
  • Persist tasks to a file between runs
  • Validate user input and show helpful error messages

Project structure

task-manager/
├── Task.java # Task record
├── TaskStore.java # File persistence
├── TaskApp.java # CLI logic and main method
└── tasks.dat # Data file (created at runtime)

Create a directory called task-manager/ and add the files below.

Step 1: the Task model

A simple record representing a task:

// Task.java

public record Task(int id, String description, boolean done) {

private static final String DELIMITER = "|";

/**
* Serialize this task to a single line for file storage.
*/
public String toLine() {
return id + DELIMITER + description + DELIMITER + done;
}

/**
* Deserialize a task from a stored line.
*
* @throws IllegalArgumentException if the line is malformed
*/
public static Task fromLine(String line) {
String[] parts = line.split("\\|", 3);
if (parts.length != 3) {
throw new IllegalArgumentException("Invalid task line: " + line);
}

int id = Integer.parseInt(parts[0].trim());
String description = parts[1].trim();
boolean done = Boolean.parseBoolean(parts[2].trim());

return new Task(id, description, done);
}

/**
* Create a completed version of this task.
*/
public Task complete() {
return new Task(id, description, true);
}

@Override
public String toString() {
String status = done ? "[x]" : "[ ]";
return String.format("%s %3d: %s", status, id, description);
}
}

Key points:

  • Records auto-generate equals, hashCode, and accessor methods
  • toLine / fromLine handle serialization (pipe-delimited, one task per line)
  • complete() returns a new Task with done = true (records are immutable)
  • toString() formats nicely for display: [ ] 1: Buy groceries

Step 2: the TaskStore (persistence)

Handles loading and saving tasks to a file:

// TaskStore.java

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public class TaskStore {

private final Path filePath;

public TaskStore(Path filePath) {
this.filePath = filePath;
}

/**
* Load all tasks from the file. Returns an empty list if the file
* does not exist.
*/
public List<Task> load() throws IOException {
if (!Files.exists(filePath)) {
return new ArrayList<>();
}

List<Task> tasks = new ArrayList<>();
for (String line : Files.readAllLines(filePath)) {
String trimmed = line.trim();
if (!trimmed.isEmpty()) {
tasks.add(Task.fromLine(trimmed));
}
}
return tasks;
}

/**
* Save all tasks to the file, overwriting previous content.
*/
public void save(List<Task> tasks) throws IOException {
List<String> lines = new ArrayList<>();
for (Task task : tasks) {
lines.add(task.toLine());
}
Files.write(filePath, lines);
}
}

This is the same pattern from the File I/O chapter -- one record per line with a simple delimiter.

Step 3: the TaskApp (CLI logic)

The main class that parses commands and orchestrates everything:

// TaskApp.java

import java.io.IOException;
import java.nio.file.Path;
import java.util.List;

public class TaskApp {

private static final Path DATA_FILE = Path.of("tasks.dat");
private final TaskStore store;

public TaskApp() {
this.store = new TaskStore(DATA_FILE);
}

// ── Commands ──────────────────────────────────────────────

void addTask(String description) throws IOException {
List<Task> tasks = store.load();
int nextId = tasks.stream()
.mapToInt(Task::id)
.max()
.orElse(0) + 1;

Task task = new Task(nextId, description, false);
tasks.add(task);
store.save(tasks);

System.out.println("Added: " + task);
}

void listTasks() throws IOException {
List<Task> tasks = store.load();

if (tasks.isEmpty()) {
System.out.println("No tasks yet. Add one with: add \"description\"");
return;
}

long total = tasks.size();
long completed = tasks.stream().filter(Task::done).count();

System.out.println("Tasks (" + completed + "/" + total + " completed):");
System.out.println("─".repeat(50));

for (Task task : tasks) {
System.out.println(task);
}
}

void completeTask(int id) throws IOException {
List<Task> tasks = store.load();

boolean found = false;
for (int i = 0; i < tasks.size(); i++) {
if (tasks.get(i).id() == id) {
Task completed = tasks.get(i).complete();
tasks.set(i, completed);
store.save(tasks);
System.out.println("Completed: " + completed);
found = true;
break;
}
}

if (!found) {
System.err.println("Error: no task with ID " + id);
}
}

void deleteTask(int id) throws IOException {
List<Task> tasks = store.load();
int sizeBefore = tasks.size();

tasks.removeIf(task -> task.id() == id);

if (tasks.size() == sizeBefore) {
System.err.println("Error: no task with ID " + id);
return;
}

store.save(tasks);
System.out.println("Deleted task " + id);
}

// ── CLI parsing ───────────────────────────────────────────

void run(String[] args) {
if (args.length == 0) {
printUsage();
return;
}

String command = args[0].toLowerCase();

try {
switch (command) {
case "add" -> {
if (args.length < 2) {
System.err.println("Error: missing task description");
System.err.println("Usage: add \"description\"");
return;
}
String description = String.join(" ",
java.util.Arrays.copyOfRange(args, 1, args.length));
addTask(description);
}
case "list" -> listTasks();
case "done" -> {
if (args.length < 2) {
System.err.println("Error: missing task ID");
System.err.println("Usage: done <id>");
return;
}
completeTask(parseId(args[1]));
}
case "delete" -> {
if (args.length < 2) {
System.err.println("Error: missing task ID");
System.err.println("Usage: delete <id>");
return;
}
deleteTask(parseId(args[1]));
}
default -> {
System.err.println("Unknown command: " + command);
printUsage();
}
}
} catch (IOException e) {
System.err.println("File error: " + e.getMessage());
}
}

private int parseId(String input) {
try {
return Integer.parseInt(input);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid ID: " + input + " (must be a number)");
}
}

private void printUsage() {
System.out.println("""
Task Manager -- a simple CLI to-do list

Usage:
add <description> Add a new task
list List all tasks
done <id> Mark a task as completed
delete <id> Delete a task

Examples:
add Buy groceries
add "Learn Java"
list
done 1
delete 2
""");
}

// ── Entry point ───────────────────────────────────────────

public static void main(String[] args) {
new TaskApp().run(args);
}
}

Step 4: compile and test

Compile all files:

cd task-manager
javac Task.java TaskStore.java TaskApp.java

Test each command:

java TaskApp add "Buy groceries"

Result:

Added: [ ]   1: Buy groceries
java TaskApp add "Learn Java"
java TaskApp add "Build a project"
java TaskApp list

Result:

Added: [ ]   2: Learn Java
Added: [ ] 3: Build a project
Tasks (0/3 completed):
──────────────────────────────────────────────────
[ ] 1: Buy groceries
[ ] 2: Learn Java
[ ] 3: Build a project
java TaskApp done 2
java TaskApp list

Result:

Completed: [x]   2: Learn Java
Tasks (1/3 completed):
──────────────────────────────────────────────────
[ ] 1: Buy groceries
[x] 2: Learn Java
[ ] 3: Build a project
java TaskApp delete 1
java TaskApp list

Result:

Deleted task 1
Tasks (1/2 completed):
──────────────────────────────────────────────────
[x] 2: Learn Java
[ ] 3: Build a project
java TaskApp

Result:

Task Manager -- a simple CLI to-do list

Usage:
add <description> Add a new task
list List all tasks
done <id> Mark a task as completed
delete <id> Delete a task

Examples:
add Buy groceries
add "Learn Java"
list
done 1
delete 2

Step 5: package as a runnable JAR

A JAR (Java ARchive) bundles your .class files into a single file that can be run with java -jar.

Create a manifest file

Create MANIFEST.MF:

Main-Class: TaskApp

Important: the manifest file must end with a newline.

Build the JAR

jar cfm tasks.jar MANIFEST.MF *.class

Breakdown:

  • c -- create a new archive
  • f -- output to a file (tasks.jar)
  • m -- include a manifest file (MANIFEST.MF)
  • *.class -- include all compiled class files

Run the JAR

java -jar tasks.jar list
java -jar tasks.jar add "Deploy to VPS"
java -jar tasks.jar list

Result:

Tasks (1/2 completed):
──────────────────────────────────────────────────
[x] 2: Learn Java
[ ] 3: Build a project
Added: [ ] 4: Deploy to VPS
Tasks (1/3 completed):
──────────────────────────────────────────────────
[x] 2: Learn Java
[ ] 3: Build a project
[ ] 4: Deploy to VPS

You now have a single tasks.jar file that can be run anywhere Java is installed.

Build script

Create build.sh to automate compilation and packaging:

#!/bin/bash
set -euo pipefail

echo "Compiling..."
javac Task.java TaskStore.java TaskApp.java

echo "Packaging..."
jar cfm tasks.jar MANIFEST.MF *.class

echo "Done! Run with: java -jar tasks.jar"
chmod +x build.sh
./build.sh

What you have built

This project ties together everything from chapters 1–9:

ConceptWhere it is used
Variables & typesTask fields, command parsing, ID handling
Control flowSwitch for commands, loop for listing
MethodsaddTask, listTasks, completeTask, deleteTask, parseId
Classes & objectsTaskApp, TaskStore -- encapsulated responsibilities
RecordsTask -- immutable data with auto-generated methods
CollectionsArrayList for in-memory task list, removeIf, streams
Error handlingtry/catch for IOException, validation for bad input
File I/OFiles.readAllLines, Files.write for persistence

Summary

  • A CLI application parses command-line arguments from String[] args.
  • Separate concerns: model (Task), persistence (TaskStore), UI (TaskApp).
  • Records with toLine/fromLine methods handle serialization.
  • Input validation with clear error messages makes the tool user-friendly.
  • jar cfm packages .class files into a runnable JAR with a manifest.

Next up: Building a REST API -- exposing the task manager over HTTP.