Tea — a strongly typed scripting language
Tea
DocsPlaygroundExamplesReferenceCommunity
GitHub

Tea Language

A strongly typed scripting language for native tools.

© 2026 Tea Language.

Agent documentationContributingCommunityRepository

Get Started

InstallationGetting StartedCLI

Tutorial

Build a CLI with Tea1. Display your first task2. Display several tasks3. Track task status4. Accept commands5. Read saved tasks6. Add tasks7. Complete tasks8. Organize and test9. Build your executable

Language

SyntaxTypesFunctionsStructsGenericsPattern MatchingError HandlingModulesTesting

Project

Code StyleContributing

Read as Markdown

Tutorial

Organize and test

Extract a module and test the logic behind your CLI.

Chapter 8 of 9 · Build a CLI with Tea

Choose a chapter
  1. Build a CLI with Tea
  2. 1. Display your first task
  3. 2. Display several tasks
  4. 3. Track task status
  5. 4. Accept commands
  6. 5. Read saved tasks
  7. 6. Add tasks
  8. 7. Complete tasks
  9. 8. Organize and test
  10. 9. Build your executable

Start with chapter 7's complete tool. It works, but commands, storage, and task logic now share one long file. We'll separate the reusable parts and put tests beside them. The command's behavior and your saved data will stay the same.

Split the file at a useful boundary

Create task_list.tea next to tasks.tea. Move the Task struct, TaskError, and the functions from format_task through complete_task into it. This module owns task data, its presentation, and its storage format. Keep help, usage, run, and the outer catch block in tasks.tea.

Move the std.string and std.parse imports into the new module. Both files need std.fs: the module performs file operations, and the entry file names fs.FsError in its error handlers.

Mark the declarations used by the command file with pub: Task, TaskError, show_tasks, load_tasks, save_tasks, add_task, and complete_task. Leave helpers such as encode_tasks private.

In tasks.tea, import the local module:

use task_list from "./task_list"

This path is relative to the source file. Call exported functions through the alias, such as task_list.load_tasks("tasks.txt"), and qualify the error type as task_list.TaskError. The two complete files below show every changed call.

Write a test beside the logic

Add use assert from "std.assert" to task_list.tea, then append a test block:

test "format a task"
  const task = Task(title: "Learn Tea", done: false)
  assert.eq(format_task(task, 1), "1. [ ] Learn Tea")
end

test names a check that Tea's test runner can discover. assert.eq fails the test if the two values differ. Because this test lives in the module, it can call the private format_task helper.

Add the other three tests from the complete module. They check that encoding and decoding agree, completing a task mutates the original collection without changing its length, and malformed data is rejected. These tests use in-memory data, so running them doesn't touch your tasks.txt.

Run the tests

tea test "$PWD/task_list.tea"

You should see four passing tests: format a task, save and restore task data, complete a task without renumbering, and reject malformed saved data. The summary reports 4 tests: 4 passed, 0 failed; timing values will vary.

Use the full test path

$PWD is your shell's current directory. The current Tea test command resolves relative input paths against the compiler's build checkout. Passing the full path makes this command work from your own project directory.

Now confirm that the refactored command still uses your saved tasks:

tea tasks.tea list --all
1. [ ] Learn Tea
2. [x] Build a CLI
3. [x] Take a break
4. [ ] Write a test

Complete checkpoint

You now have two source files. Copy both if you are resuming here; keep your existing data file. The first contains the CLI, and the second contains the task logic and its tests.

Complete tasks.tea for this chapter

tasks.tea

use args from "std.args"
use fs from "std.fs"
use task_list from "./task_list"

def help() -> Void
  @println("Usage: tasks [list [--all] | add \"title\" | done NUMBER | --help]")
  @println("Tasks are numbered from 1. Listing shows open tasks; --all includes completed tasks.")
end

def usage() -> Void
  @eprintln("tasks: invalid arguments; run tasks --help for usage")
  @exit(2)
end

def run() -> Void ! { fs.FsError, task_list.TaskError }
  var argv = args.all()

  if @len(argv) == 0
    argv = ["list"]
  end

  if argv[0] == "--help" && @len(argv) == 1
    help()
    return
  end

  if argv[0] == "list"
    if @len(argv) > 2 ||(@len(argv) == 2 && argv[1] != "--all")
      usage()
      return
    end

    const tasks = task_list.load_tasks("tasks.txt")
    task_list.show_tasks(tasks, @len(argv) == 2)
    return
  end

  if argv[0] == "add" && @len(argv) == 2
    const tasks = task_list.load_tasks("tasks.txt")
    task_list.add_task(tasks, argv[1])
    task_list.save_tasks("tasks.txt", tasks)
    @println(`Added task ${@len(tasks)}.`)
    return
  end

  if argv[0] == "done" && @len(argv) == 2
    const tasks = task_list.load_tasks("tasks.txt")

    if task_list.complete_task(tasks, argv[1])
      task_list.save_tasks("tasks.txt", tasks)
      @println(`Completed task ${argv[1]}.`)
    else
      @println(`Task ${argv[1]} is already complete.`)
    end

    return
  end

  usage()
end

try run() catch err
  case is fs.FsError.OperationFailed
    @eprintln(`tasks: cannot ${err.operation} '${err.path}': ${err.message}`)
    @exit(1)
  case is task_list.TaskError.InvalidData
    @eprintln(`tasks: invalid data on line ${err.line}; expected [ ] title or [x] title`)
    @exit(1)
  case is task_list.TaskError.InvalidTitle
    @eprintln("tasks: title must be nonempty and on one line")
    @exit(2)
  case is task_list.TaskError.InvalidNumber
    @eprintln(`tasks: '${err.input}' is not an existing task number; use list --all`)
    @exit(2)
  case _
    @eprintln("tasks: unexpected error")
    @exit(1)
end
Complete task_list.tea for this chapter

task_list.tea

use assert from "std.assert"
use fs from "std.fs"
use parse from "std.parse"
use string from "std.string"

pub struct Task {
  title: String
  done: Bool
}

def format_task(task: Task, number: Int) -> String
  const marker = if(task.done) "[x]" else "[ ]"
  `${number}. ${marker} ${task.title}`
end

pub def show_tasks(tasks: List[Task], show_all: Bool) -> Void
  if @len(tasks) == 0
    @println("No tasks yet.")
    return
  end

  var number = 1
  var shown = 0

  for task in tasks
    if show_all ||!task.done
      @println(format_task(task, number))
      shown = shown + 1
    end

    number = number + 1
  end

  if shown == 0
    @println("No open tasks.")
  end
end

pub error TaskError {
  InvalidData(line: Int)
  InvalidTitle
  InvalidNumber(input: String)
}

def valid_title(title: String) -> Bool
  const without_spaces = string.replace(title, " ", "")
  const without_tabs = string.replace(without_spaces, "\t", "")
  @len(without_tabs) > 0 &&!string.contains(title, "\n") &&!string.contains(title, "\r")
end

def decode_tasks(content: String) -> List[Task] ! TaskError
  var tasks: List[Task] = []
  var number = 1

  for line in string.lines(string.replace(content, "\r\n", "\n"))
    if @len(line) < 5 ||!(string.starts_with(line, "[ ] ") || string.starts_with(line, "[x] "))
      throw TaskError.InvalidData(number)
    end

    const title = line[4..@len(line)]

    if ! valid_title(title)
      throw TaskError.InvalidData(number)
    end

    @append(tasks, Task(title: title, done: string.starts_with(line, "[x] ")))
    number = number + 1
  end

  tasks
end

pub def load_tasks(file: String) -> List[Task] ! { fs.FsError, TaskError }
  if ! fs.exists(file)
    return[]
  end

  decode_tasks(fs.read_file(file))
end

def encode_tasks(tasks: List[Task]) -> String
  var content = ""

  for task in tasks
    const marker = if(task.done) "[x]" else "[ ]"
    content = content + `${marker} ${task.title}\n`
  end

  content
end

pub def save_tasks(file: String, tasks: List[Task]) -> Void ! fs.FsError
  fs.write_file_atomic(file, encode_tasks(tasks))
end

pub def add_task(tasks: List[Task], title: String) -> Void ! TaskError
  if ! valid_title(title)
    throw TaskError.InvalidTitle
  end

  @append(tasks, Task(title: title, done: false))
end

pub def complete_task(tasks: List[Task], input: String) -> Bool ! TaskError
  if @len(input) == 0 || @len(input) > 19
    throw TaskError.InvalidNumber(input)
  end

  var i = 0

  while i < @len(input)
    if ! string.contains("0123456789", input[i])
      throw TaskError.InvalidNumber(input)
    end

    i = i + 1
  end

  const number = try parse.try_int(input) catch err
    case is parse.ParseError.InvalidInt
      throw TaskError.InvalidNumber(err.input)
    case _
      throw TaskError.InvalidNumber(input)
  end

  if number < 1 || number > @len(tasks) || @to_string(number) != input
    throw TaskError.InvalidNumber(input)
  end

  const task = tasks[number - 1]

  if task.done
    return false
  end

  task.done = true
  true
end

test "format a task"
  const task = Task(title: "Learn Tea", done: false)
  assert.eq(format_task(task, 1), "1. [ ] Learn Tea")
end

test "save and restore task data"
  const tasks = [
    Task(title: "Learn Tea", done: false),
    Task(title: "Take a break", done: true)
  ]
  const restored = decode_tasks(encode_tasks(tasks))
  assert.eq(@len(restored), 2)
  assert.eq(restored[0].title, "Learn Tea")
  assert.eq(restored[1].done, true)
end

test "complete a task without renumbering"
  const tasks = [Task(title: "Learn Tea", done: false)]
  assert.eq(complete_task(tasks, "1"), true)
  assert.eq(tasks[0].done, true)
  assert.eq(complete_task(tasks, "1"), false)
  assert.eq(@len(tasks), 1)
end

test "reject malformed saved data"
  try decode_tasks("not a task\n") catch err
    case is TaskError.InvalidData
      assert.eq(err.line, 1)
      return
    case _
      assert.ok(false)
      return
  end
  assert.ok(false)
end

Try it yourself

Add a test for formatting a completed task. Deliberately give the assertion the wrong expected marker, run the tests, then correct it.

One possible solution
test "format a completed task"
  const task = Task(title: "Take a break", done: true)
  assert.eq(format_task(task, 3), "3. [x] Take a break")
end

With this optional test included, the summary will report five tests. The main walkthrough has four.

You have a reusable module and an automated way to check its behavior. Next, we'll compile the tool into an executable.

Read more: modules, testing, assertions.

Previous7. Complete tasksNext9. Build your executable