1. Why command logic is needed in a console application
A console program without commands is like a coffee shop without a menu: it seems there is coffee somewhere, but it is unclear how to order it. Command logic is needed so the user can control the application state with short, clear phrases: add ..., list, remove .... This turns a set of scattered functions into a single workflow: read a line → understand the meaning → change a collection → show the result.
Let’s imagine we are writing a mini app TaskBox — a task list in the console. This is not a “real” space-station-level TODO list, but a learning version: add a task, show the list, delete a task. What matters is not feature richness, but architecture: the code should be clear and should not break collections during iteration.
The application state will be simple:
- MutableList<String> — the list of tasks (each task is just a string).
- a command-reading loop — the user enters lines until they type exit.
Command and argument
Almost all console utilities follow the principle: there is a command (what to do) and sometimes there is an argument (what to do it with). We do not need complicated syntax with quotes and escaping (that is a separate kind of pain), so we take a practical learning format: the command is the first word, the argument is everything after the first space. That is enough to build a robust workflow without extra magic.
This is what it will look like for TaskBox:
| Command | Input example | Meaning |
|---|---|---|
|
|
add a task |
|
|
print all tasks |
|
|
remove a task by number (human-friendly) |
|
|
remove a task by exact text (if it does not look like a number) |
|
|
show help |
|
|
exit the program |
Key agreement: the user’s numbering starts at 1, while list indices start at 0. This is that “timeless classic” because of which programmers sometimes talk to people with a slight sadness in their eyes.
2. Normalization and parsing of input
String normalization
Before parsing the command, it is useful to make the line a bit cleaner: remove leading/trailing spaces and unify the command’s case. This is not decoration — it is protection from input like " LIST " or "Add buy milk". The user is not obligated to remember our ideal format; our job is to make the program patient (within reason).
The rule will be: convert the command to lowercase, keep the argument as-is (except for trim()), because task text can be in any case, and we do not want to suddenly turn “Buy Milk” into “buy milk”.
Mini example of normalization:
fun normalizeInput(line: String): String {
return line.trim()
}
fun main() {
val raw = " Add buy milk "
val normalized = normalizeInput(raw)
println(normalized) // Add buy milk
}
Note: we do not “compress” multiple spaces inside the string. We do not need that yet. In a learning parser it is enough to split by the first space, and then treat the argument “as-is”.
Command parsing
Now we will create a function that takes a line and returns Pair<String, String?>: first — the command, second — the argument, or null if there is none. This is exactly the moment when Pair becomes a convenient “envelope” for two values, without inventing our own types (custom classes will come later in the course).
It is important to handle three cases: an empty string (after trim()), a string without spaces (command only), and a string with a command and an argument.
fun parseCommand(line: String): Pair<String, String?> {
val s = line.trim()
if (s.isEmpty()) return "" to null
val spaceIndex = s.indexOf(' ')
return if (spaceIndex == -1) {
s.lowercase() to null
} else {
val cmd = s.substring(0, spaceIndex).lowercase()
val arg = s.substring(spaceIndex + 1).trim()
cmd to arg
}
}
An important detail here: substring(spaceIndex + 1) takes “everything after the space”. If the user types add buy milk, then arg will start with spaces, so we call trim() again to get clean text.
Let’s test it on a few inputs:
fun main() {
println(parseCommand("list")) // (list, null)
println(parseCommand(" add milk ")) // (add, milk)
println(parseCommand(" ")) // (, null)
}
The printed Pair format looks like (first, second). That is fine: this is how Kotlin shows pairs in toString().
3. Dispatching commands with when
Once the command is recognized, we need to decide what to do. when is perfect for this: it reads like a routing table — “if the command is this, do that.” It is important to keep branches short and clear: a command should do one thing and not try to “reinvent the whole world” at the same time.
Let’s agree on the TaskBox architecture right away:
- main() — only the input loop and function calls.
- parseCommand() — parsing only.
- handleCommand() — a dispatcher that calls specific operations.
- operations (addTask, printTasks, removeTask) — separate functions so we do not bloat when.
We will start with the base: the command handler returns a Boolean: true — continue the loop, false — exit.
fun handleCommand(cmd: String, arg: String?, tasks: MutableList<String>): Boolean {
return when (cmd) {
"add" -> { addTask(tasks, arg); true }
"list" -> { printTasks(tasks); true }
"remove" -> { removeTask(tasks, arg); true }
"help" -> { printHelp(); true }
"exit" -> false
"" -> true
else -> { println("Unknown command: $cmd"); true }
}
}
Note the "" branch: this is the case when the user entered an empty line or just spaces. We simply do nothing and continue.
4. The add and list commands
The add command
Adding an element is the simplest list mutation: we take a string and put it into tasks. In Kotlin, mutable collections have an add() method that appends an element to the end. But in a console UI, the main difficulty is not add(), but that the user might forget the argument or enter an empty line.
Let’s write a neat function: if the argument is missing or empty, we print a hint and change nothing.
fun addTask(tasks: MutableList<String>, text: String?) {
val task = text?.trim()
if (task.isNullOrEmpty()) {
println("Format: add <task text>")
return
}
tasks.add(task)
println("Added: $task") // Added: buy milk
}
Here isNullOrEmpty() is a convenient way to say “either null or an empty string.” We could do two checks manually, but let Kotlin do a bit of work for us.
The list command
Printing the list is a “read-only” operation. It is easy to make it either clear, or “I’m a programmer, this is how I see it.” We need to print tasks with numbers starting from 1, because that is more natural for users: item 1, item 2, item 3.
Technically you can number in different ways: via indices or withIndex(). For this command, withIndex() is more logical, because we want both the index and the value. In previous lectures we discussed that list indices start at 0 and go up to lastIndex, that is, up to size - 1.
fun printTasks(tasks: List<String>) {
if (tasks.isEmpty()) {
println("The task list is empty.") // The task list is empty.
return
}
for ((i, task) in tasks.withIndex()) {
println("${i + 1}. $task")
}
}
Notice: the parameter here is List<String>, not MutableList<String>. This is good style: the “print” function does not have to mutate the list, so we make the contract stricter — read-only.
5. The remove command and safe deletion
Deleting by number or by text
Deletion is the most nerve-wracking operation. Not because remove is complex, but because around it people often end up with “I’ll just delete it right during iteration,” and then the adventures begin. We already know safe deletion strategies, but within the remove command, most of the time it is enough to delete one element.
There are two convenient semantics:
- if the argument is a number, we treat it as the task’s number (starting from 1), convert it to an index (minus 1), check bounds, and call removeAt(index);
- if the argument is not a number, we delete by value via remove(value).
A key detail about remove(value): it removes one occurrence and returns a Boolean indicating whether deletion happened.
Let’s write a neat implementation:
fun removeTask(tasks: MutableList<String>, arg: String?) {
val s = arg?.trim()
if (s.isNullOrEmpty()) {
println("Format: remove <number|text>")
return
}
val number = s.toIntOrNull()
if (number != null) {
removeByNumber(tasks, number)
} else {
removeByText(tasks, s)
}
}
Now the “by number” specifics:
fun removeByNumber(tasks: MutableList<String>, number: Int) {
val index = number - 1
if (index !in tasks.indices) {
println("No task with number $number")
return
}
val removed = tasks.removeAt(index)
println("Removed: $removed") // Removed: buy milk
}
Here tasks.indices is the most reliable way to check bounds: it is the range of valid indices. It is automatically correct for any list size and saves you from the classic mistake “mixed up size and last index.” The index principle (0…lastIndex) is the core list model in Kotlin.
And deletion “by text”:
fun removeByText(tasks: MutableList<String>, text: String) {
val ok = tasks.remove(text)
if (ok) {
println("Removed: $text") // Removed: buy milk
} else {
println("Task not found: $text")
}
}
Note: “delete by text” will remove the first match. If there are multiple identical tasks, this behavior is usually fine for a simple learning application.
Multiple deletion via an iterator
Sometimes you do not want “remove one element,” but “remove all tasks equal to X” or “remove all empty/invalid ones.” This is where people step on rakes: they do for (x in tasks) tasks.remove(x) and get either skipped elements or an exception.
Today we are not building a rich purge command, but we will create a useful helper function removeAllEquals. It will be handy as an example of “correct deletion during traversal” — via an iterator. This is exactly the technique we prepared in the lecture about safe deletions.
fun removeAllEquals(tasks: MutableList<String>, target: String) {
val it = tasks.iterator()
while (it.hasNext()) {
val task = it.next()
if (task == target) {
it.remove()
}
}
}
Why is it important to remember this even in a “simple” TaskBox? Because as soon as you expand the functionality (for example, add a command “remove everything that contains the word spam”), you will end up in a multiple-deletion situation. And there you cannot “hope for the best” — you must choose a correct traversal and deletion strategy.
6. Assembling the app: main, help, and the command loop
Now let’s assemble everything into one workflow. It is important to keep main() as the “conductor”: it should not parse, validate, and delete all by itself. Its job is to read lines, call parseCommand, pass the result to handleCommand, and decide whether to continue the loop.
A small flowchart of the process:
flowchart TD
A["readln()"] --> B[trim + parseCommand]
B --> C[cmd + arg]
C --> D["when(cmd)"]
D --> E[add/list/remove/help]
E --> F[tasks updated and/or result printed]
F --> A
D --> G[exit]
And now the main() code (still short and readable):
fun main() {
val tasks = mutableListOf<String>()
println("TaskBox. Type help for assistance.")
while (true) {
print("> ")
val (cmd, arg) = parseCommand(readln())
val shouldContinue = handleCommand(cmd, arg, tasks)
if (!shouldContinue) break
}
println("Bye!") // Bye!
}
And the help function, so the user does not feel abandoned in the console face-to-face with fate:
fun printHelp() {
println("Commands:")
println(" add <text> — add a task")
println(" list — show tasks")
println(" remove <...> — remove by number or exact text")
println(" exit — exit")
}
Yes, there is a mini list here. In help, that is normal: help without a list is like a microwave manual that says “you’ll figure it out.”
7. Common mistakes
Mistake #1: mixing up “number” and “index” and deleting the wrong thing.
The most common bug with remove 2: a person thinks “the second element,” but the code does removeAt(2) and deletes the third, because list indexing starts at zero. Fix it with a simple rule: user number → index = number - 1, then check index in tasks.indices.
Mistake #2: checking bounds with index < size and forgetting negative values.
Beginners often write if (index < tasks.size) ... and forget that index can be -1, if the user enters remove 0. Checking via index in tasks.indices covers both sides at once and makes the code shorter, because the index range is already correct.
Mistake #3: removing list elements inside a for loop via tasks.remove(...).
If you try to remove elements directly from a collection during for (x in tasks), you can get skipped elements or a collection-modification exception. For single deletion, it is better not to iterate at all, but remove by index/value in one operation. For multiple deletion, use an iterator with it.remove().
Mistake #4: not distinguishing remove(value) and removeAt(index).
remove("milk") — removes by value and returns a Boolean indicating whether it succeeded. removeAt(1) — removes by position and returns the removed element. If you mix them up, the code either will not compile, or will behave differently than the user expects.
Mistake #5: not handling an empty argument for add and remove.
Commands without an argument are normal reality: the user may press Enter too early or forget to finish typing. If you do not check arg, you will get either empty tasks in the list or strange attempts to delete “nothing.” A simple check arg?.trim().isNullOrEmpty() makes behavior predictable and friendly.
Mistake #6: bloating main() into a “god method.”
It is very easy to start writing everything directly in main: parsing, when, checks, printing, deletion, plus a couple more “quick” things. After 20 minutes you get a function that you are afraid to touch, because it “works, but I don’t know why.” The cure is discipline: parsing separately, dispatch separately, operations separately. This is not “architecture for architecture’s sake,” it is a way to not go crazy on the first change in requirements.