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
Think about the loop the program lives in, before any code:
3 The Build — explained part by part
Here is the complete to-do list. Read each part’s note below — you should understand the whole thing from the notes alone.
import java.io.File
private val FILE = File("tasks.txt")
/** One task: done or not, plus its text. Stored as "0|text" / "1|text" per line. */
data class Task(val done: Boolean, val text: String) {
fun toLine(): String = (if (done) "1" else "0") + "|" + text
companion object {
fun fromLine(line: String): Task {
val (flag, text) = line.split("|", limit = 2)
return Task(flag == "1", text)
}
}
}
/** If a saved file exists, read the tasks from it; otherwise start empty. */
fun loadTasks(): MutableList<Task> =
if (FILE.exists())
FILE.readLines().filter { it.isNotBlank() }.map { Task.fromLine(it) }.toMutableList()
else
mutableListOf()
/** Writes the tasks to the file so they survive after the program closes. */
fun saveTasks(tasks: List<Task>) {
FILE.writeText(tasks.joinToString("\n") { it.toLine() })
}
fun addTask(tasks: MutableList<Task>, text: String): MutableList<Task> {
tasks.add(Task(false, text))
return tasks
}
fun removeTask(tasks: MutableList<Task>, number: Int): MutableList<Task> {
if (number in 1..tasks.size) tasks.removeAt(number - 1)
return tasks
}
fun main() {
val tasks = loadTasks()
while (true) {
println("\n1. Add 2. View 3. Remove 4. Quit")
print("Choose: ")
val choice = readLine()?.trim() ?: break
when (choice) {
"1" -> {
print("New task: ")
val text = readLine() ?: ""
addTask(tasks, text)
saveTasks(tasks)
}
"2" -> tasks.forEachIndexed { i, t ->
println("${i + 1}. [${if (t.done) "x" else " "}] ${t.text}")
}
"3" -> {
print("Remove which number? ")
val number = readLine()?.trim()?.toIntOrNull()
if (number != null && number in 1..tasks.size) {
removeTask(tasks, number)
saveTasks(tasks)
}
}
"4" -> return
else -> println("Please choose 1 to 4.")
}
}
}kotlinc on your own machine instead; the “Run It” section explains exactly how.data class gets a correct equals, hashCode, toString, and copy() for free, from a one-line declaration; the tests below rely on that free equals to compare tasks directly.fun toLine() / fun fromLine(line: String) — a member function and a
companion object factory function that convert a Task to and from the plain-text "0|text"/"1|text" format saved on disk, matching the Java sibling's own persistence format exactly.File("tasks.txt").readLines() — Kotlin adds this convenience directly onto the JDK's own
java.io.File, so reading every line of a file is one call, no explicit stream or scanner required.fun addTask(tasks: MutableList<Task>, text: String): MutableList<Task> — mutates and returns the same list (mirroring the Python/Go versions' shape) so the tests can check the result without any file I/O at all.
"|" with no limit, so a task whose own text contains a | character breaks the format.line.split("|", limit = 2) only splits once, no matter how many | characters appear afterward, keeping the rest of the line intact as the task text.class instead of a data class for Task, then being confused when assertEquals fails for two tasks that look identical.data class compares by its actual field values, which is what every test here expects.add, remove) calls saveTasks immediately afterward, so a crash between operations never silently loses work beyond the last save.4 Test & Prove Each Part
How do we know this works? We pull the real logic into small, plain functions and check each one against cases we already know the answer to.
import kotlin.test.Test
import kotlin.test.assertEquals
class TodoTest {
@Test
fun addingPutsItInTheList() {
val tasks = addTask(mutableListOf(), "Buy milk")
assertEquals(listOf(Task(false, "Buy milk")), tasks)
}
@Test
fun removingTakesTheFirstItemOut() {
val tasks = mutableListOf(Task(false, "a"), Task(false, "b"))
assertEquals(listOf(Task(false, "b")), removeTask(tasks, 1))
}
@Test
fun removingAMissingNumberLeavesTheListUnchanged() {
val tasks = mutableListOf(Task(false, "a"))
assertEquals(listOf(Task(false, "a")), removeTask(tasks, 9))
}
}Compile with kotlinc todo.kt todo_test.kt -include-runtime -d todo.jar and run with JUnit's own runner. addTask/removeTask operate on a plain MutableList<Task>, so the tests never touch tasks.txt at all.
5 The Interface
What it expects
Choose: 1
New task: Walk the dogWhat it returns
1. Walk the dog
2. Buy milk6 Run It & Automate It
Save the code as todo.kt and compile it with kotlinc todo.kt -include-runtime -d todo.jar. It keeps a menu running until you choose Quit.
kotlinc todo.kt -include-runtime -d todo.jar && java -jar todo.jarAdd a task, view the list, remove one by number, then quit — the list is saved to tasks.txt between runs.
A CI tool like Jenkins compiles and tests automatically whenever the code changes — every line below has a plain explanation.
1. Add 2. View 3. Remove 4. Quit
Choose: 1
New task: Buy milk
1. Add 2. View 3. Remove 4. Quit
Choose: 2
1. [ ] Buy milk
1. Add 2. View 3. Remove 4. Quit
Choose: 4tasks.txt is saved relative to the current working directory, not next to the .jar file.removeTask already accounts for this internally — just make sure you are reading the current list's numbering, not a stale one from before an earlier add or remove.// Jenkinsfile — compiles and tests automatically every time the code changes.
pipeline {
agent any // run on any available machine
stages {
stage('Get the code') {
steps { checkout scm } // download the latest code
}
stage('Set up Kotlin') {
steps {
sh 'kotlinc -version' // confirm the compiler is installed
}
}
stage('Compile and test') {
steps {
sh 'kotlinc todo.kt todo_test.kt -include-runtime -d build.jar' // one real JVM jar, no build tool required
sh 'java -cp build.jar:kotlin-test-junit.jar:junit.jar org.junit.runner.JUnitCore TodoTest'
}
}
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
You have a working to-do list. Extend it:
- Add a “mark done” option. Toggle a task's
doneflag instead of only adding/removing. (Teaches:data class'scopy()for an immutable update.) - Sort done tasks to the bottom. Change the order
Viewprints them in. (Teaches:sortedBy.) - Validate the remove number. Print a clearer message for an out-of-range number instead of silently doing nothing. (Teaches: returning a result the caller can report on.)
- Switch to JSON. Compare this plain-text format against a structured one. (Teaches: trade-offs between a hand-rolled format and a library.)
data class for a value with free, correct equality; a companion object as the idiomatic home for a factory function; and reading/writing plain text files through the JDK's File with Kotlin's own convenience extensions. Related reference: Data Classes, Collections in Kotlin.