<ContentPage eyebrow="Tutorial" title="Read saved tasks" description="Load a text file and handle failures with typed errors.">

<TutorialNavigation slug="tutorial-read-tasks" />

Start with chapter 4's command-aware program. We'll keep the commands and display functions, but replace the embedded sample list with tasks read from a file.

## Give the data a home

Create `tasks.txt` beside `tasks.tea` with this content:

**`tasks.txt`**

```text checkpoint="examples/tutorial/tasks/05-read-tasks/tasks.txt"
[ ] Learn Tea
[ ] Build a CLI
[x] Take a break
```

Each line starts with `[ ] ` for an open task or `[x] ` for a completed task, followed by its title. Numbers are positions in the file, starting at 1; we don't need to store them separately.

Run commands from your project directory. `tasks.txt` is relative to the directory where you run the command, not the location of the Tea source or executable. This lets different directories have separate task lists.

## Import file and string helpers

Add `use fs from "std.fs"` and `use string from "std.string"` beside the existing import. In the `list` branch of `run`, replace the embedded task list with:

```tea
const tasks = load_tasks("tasks.txt")
```

We'll add the functions it needs before `run`. Start with a domain error:

```tea
error TaskError {
  InvalidData(line: Int)
}
```

An error can have named variants and carry useful information. Here the line number lets us explain where a saved file is malformed.

## Turn text into tasks

Add `valid_title` and `decode_tasks` from the complete checkpoint. `valid_title` rejects titles containing only spaces or tabs, and titles containing a newline or carriage return. It preserves other text, including punctuation and Unicode.

`decode_tasks` builds an initially empty `List[Task]`. An empty list doesn't provide an element type to infer, so we annotate it explicitly. `@append(tasks, value)` adds a task to the list.

The decoder normalizes Windows line endings, splits the content into lines, and checks each line's prefix. `line[4..@len(line)]` takes the title after the four-character marker. The `..` range excludes its endpoint. A malformed line triggers `throw TaskError.InvalidData(number)` rather than silently disappearing from the list.

## Make failures part of the signature

Add this loading function:

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

  decode_tasks(fs.read_file(file))
end
```

The `!` after the return type declares errors the function can raise. The braces list two possible error types: file operations can fail with `fs.FsError`, and decoding can fail with `TaskError`.

Tea propagates those errors through the calls in this function. The same error set must be declared on `run`, because it calls `load_tasks`. A missing file is treated as an empty list; other filesystem failures propagate.

Replace the final `run()` call with the `try run() catch err` block in the complete checkpoint. Its `case is` arms handle each error variant. A filesystem error carries an operation, path, and message; our data error carries a line number. Both go to standard error and end the program with `@exit(1)`.

## Read the saved list

```bash
tea tasks.tea list --all
```

```text
1. [ ] Learn Tea
2. [ ] Build a CLI
3. [x] Take a break
```

Change `Learn Tea` to another title in `tasks.txt` and run again. You should see the changed title without editing `tasks.tea`. Restore the sample title before continuing so your output matches the next chapters.

## Complete checkpoint

Keep the `tasks.txt` file you just created. Replace `tasks.tea` with this version if you need a fresh starting point:

<details>
<summary>Complete tasks.tea for this chapter</summary>

**`tasks.tea`**

```tea checkpoint="examples/tutorial/tasks/05-read-tasks/tasks.tea"
use args from "std.args"
use fs from "std.fs"
use string from "std.string"

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

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

error TaskError {
  InvalidData(line: Int)
}

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

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

  decode_tasks(fs.read_file(file))
end

def help() -> Void
  @println("Usage: tasks [list [--all] | --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, 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 = load_tasks("tasks.txt")
    show_tasks(tasks, @len(argv) == 2)
    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 TaskError.InvalidData
    @eprintln(`tasks: invalid data on line ${err.line}; expected [ ] title or [x] title`)
    @exit(1)
  case _
    @eprintln("tasks: unexpected error")
    @exit(1)
end
```

</details>

## Try it yourself

Temporarily remove the marker from the second line of `tasks.txt`. Run the program and inspect the error, then restore the file.

<details>
<summary>Expected result</summary>

The program reports `tasks: invalid data on line 2; expected [ ] title or [x] title`. It doesn't print a partial list or change the file. Tea's launcher may add its own unsuccessful-exit message.

</details>

You can now turn external data into typed values and explain a failure to your user. Next, we'll write updated values back to disk.

Read more: [typed errors](/docs/error-handling), [filesystem operations](/reference/fs), [string helpers](/reference/string).

<TutorialNavigation slug="tutorial-read-tasks" position="bottom" />

</ContentPage>
