Õppetunnile juurdepääsu kontrollimine...

Saving to Disk

Save Tasks to a File

Video ja lugemine29 minEelvaade

Vaatad tasuta eelvaate õppetundi.

Goal

By the end of this lesson, Tasker remembers your tasks. The shell loads tasks from a JSON file when it starts and saves them after every change. When you quit and start again, your tasks are still there.

What you will learn

  • How to read and write files with the os package
  • How to build file paths that work on every operating system
  • How to handle a file that does not exist yet
  • How to wrap errors with context using %w, and why %v is not enough
  • How defer runs cleanup code when a function ends
  • How to write a file safely, so a crash cannot destroy your data
  • How to read an environment variable

Why this matters

Almost every real program stores data somewhere: in files, in a database, or in another service. Each of these can fail in ways that your code does not control. The disk can be full, a folder can be missing, or another program can change the file. In this lesson, you learn to handle these failures clearly, and to report them with enough context that a user can fix the problem.

Step 1: Wrapping errors with %w

When an error travels up through several functions, each function should add a little context. Then the final message tells the whole story, for example:

load /home/ana/.tasker/tasks.json: task 1: title is empty

You can read it from left to right: loading the file failed, because task 1 is invalid, because its title is empty.

In Lesson 3.2, LoadJSON added context with %v:

return fmt.Errorf("task %d: %v", t.ID, err)

There is a problem with this. Try it in play/main.go:

package main

import (
	"errors"
	"fmt"

	"tasker/task"
)

func main() {
	withV := fmt.Errorf("task 1: %v", task.ErrEmptyTitle)
	withW := fmt.Errorf("task 1: %w", task.ErrEmptyTitle)
	fmt.Println(errors.Is(withV, task.ErrEmptyTitle), errors.Is(withW, task.ErrEmptyTitle))
	fmt.Println(errors.Unwrap(withW) == task.ErrEmptyTitle)
}

Output:

false true
true

Both errors print the same message, but they are different inside:

  • %v copies the text of the error into a new error. The original error is lost, so errors.Is cannot find it.
  • %w ("wrap") keeps the original error inside the new one. errors.Is looks through every layer of wrapping and finds it. errors.Unwrap returns the error one layer down.

Rule: when you add context to an error, use %w. Now open task/store.go and change the line in LoadJSON:

return fmt.Errorf("task %d: %w", t.ID, err)

This is why you used errors.Is instead of == in Lesson 2.4. errors.Is still works after wrapping.

Step 2: Load tasks from a file

Create task/file.go:

package task

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
)

// Load reads tasks from the JSON file at path.
// If the file does not exist yet, the store stays empty and Load returns no error.
func (s *Store) Load(path string) error {
	data, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		return nil
	}
	if err != nil {
		return fmt.Errorf("read task file: %w", err)
	}

	if err := s.LoadJSON(data); err != nil {
		return fmt.Errorf("load %s: %w", path, err)
	}
	return nil
}
  • os.ReadFile reads a whole file and returns its content as []byte. That is exactly what LoadJSON needs.
  • errors.Is(err, os.ErrNotExist) checks if the file is missing. The first time you use Tasker, there is no file yet, and that is normal. So Load returns nil and the store stays empty. Here, the standard library uses the same sentinel error pattern that you used in Lesson 2.4.
  • Other read errors, such as missing permission to read the file, are real problems. Load wraps them with context and returns them.

Step 3: Save tasks safely

Imagine Tasker writes directly to tasks.json, and the computer loses power in the middle of the write. The file now has half of the JSON. On the next start, LoadJSON fails, and all your tasks are lost.

Programs avoid this with a simple trick: write the data to a temporary file first, then rename it over the real file. A rename is a single, very fast step for the operating system. The real file is always either the complete old version or the complete new version, never something in between.

Add Save to task/file.go:

// Save writes all tasks to the JSON file at path.
// It writes to a temporary file first, so a crash never leaves a half-written file.
func (s *Store) Save(path string) error {
	data, err := s.ToJSON()
	if err != nil {
		return fmt.Errorf("encode tasks: %w", err)
	}

	dir := filepath.Dir(path)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return fmt.Errorf("create folder: %w", err)
	}

	tmp, err := os.CreateTemp(dir, "tasks-*.tmp")
	if err != nil {
		return fmt.Errorf("create temporary file: %w", err)
	}
	defer os.Remove(tmp.Name())

	if _, err := tmp.Write(data); err != nil {
		tmp.Close()
		return fmt.Errorf("write temporary file: %w", err)
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("close temporary file: %w", err)
	}

	if err := os.Rename(tmp.Name(), path); err != nil {
		return fmt.Errorf("replace task file: %w", err)
	}
	return nil
}

Step by step:

  1. filepath.Dir(path) returns the folder part of the path. For /home/ana/.tasker/tasks.json, it is /home/ana/.tasker.
  2. os.MkdirAll creates the folder and any missing parent folders. If the folder already exists, it does nothing.
  3. os.CreateTemp creates a new file with a random name in the same folder, for example tasks-123456.tmp. The * in the pattern is replaced by the random part. The temporary file must be in the same folder as the real file, because a rename only works reliably within one disk.
  4. tmp.Write(data) writes the JSON. It returns the number of bytes written, which you do not need here, so it goes to _.
  5. tmp.Close() finishes the file. Closing can fail too, for example when the disk is full, so you check the error.
  6. os.Rename moves the temporary file to the real path and replaces the old file.

0o755 is a file permission in octal notation, a number system used for Unix permissions. It means: the owner can read, write, and open the folder, and other users can only read and open it. On Windows, Go mostly ignores these numbers.

Step 4: defer cleans up

Look at this line in Save:

defer os.Remove(tmp.Name())

defer schedules a function call to run when the surrounding function returns, no matter which return statement it uses. It is perfect for cleanup:

  • If Write fails, Save returns early, and the deferred Remove deletes the broken temporary file.
  • If everything succeeds, the temporary file has already been renamed. Remove finds nothing to delete, and its error is ignored.

Without defer, you would need to call os.Remove before every return, and it is easy to forget one. Write the defer right after you create a resource, so the creation and the cleanup sit next to each other in the code.

Several defer statements run in reverse order: the last one you defer runs first. You will see this when you work with database connections and HTTP servers in Part 2.

Step 5: Choose where to store the file

Create path.go in the main package:

package main

import (
	"fmt"
	"os"
	"path/filepath"
)

// dataPath returns the path of the task file.
// The TASKER_FILE environment variable can change it.
func dataPath() (string, error) {
	if path := os.Getenv("TASKER_FILE"); path != "" {
		return path, nil
	}

	home, err := os.UserHomeDir()
	if err != nil {
		return "", fmt.Errorf("find home folder: %w", err)
	}
	return filepath.Join(home, ".tasker", "tasks.json"), nil
}
  • os.UserHomeDir returns the user's home folder, for example /home/ana on Linux, /Users/ana on macOS, or C:\Users\ana on Windows.
  • filepath.Join joins path parts with the correct separator for the operating system: / on Linux and macOS, \ on Windows. Never join paths with + "/" +.
  • os.Getenv("TASKER_FILE") reads an environment variable, a setting that the user or the system passes to a program. If it is empty, Tasker uses the default path. This makes testing easy: you can point Tasker at a test file without touching your real tasks. Many command-line tools work this way.

Step 6: Load and save in the shell

Open shell.go. Change the start of runShell:

func runShell() {
	path, err := dataPath()
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}

	store := task.NewStore()
	if err := store.Load(path); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
	fmt.Printf("Loaded %d tasks from %s\n", store.Len(), path)

	scanner := bufio.NewScanner(os.Stdin)

If loading fails, the shell stops. This is on purpose. If Tasker started with an empty store and then saved, it would overwrite the user's file and lose all the tasks in it.

Now save after every change. In the add case, add the save before the success message:

			if err := store.Save(path); err != nil {
				fmt.Println("error:", err)
				continue
			}
			fmt.Println("Added:", t)

Do the same in the done case, before fmt.Println("Completed:", t).

Step 7: Try it

Use a test file, so your experiments stay separate from your real tasks:

go build
export TASKER_FILE=./data/tasks.json
./tasker shell

In Windows PowerShell, use $env:TASKER_FILE = ".\data\tasks.json" instead of export.

Loaded 0 tasks from ./data/tasks.json
> add Buy milk #shop
Added: 1. [ ] Buy milk #shop
> add Read book
Added: 2. [ ] Read book
> done 2
Completed: 2. [x] Read book
> quit
Bye!

Open data/tasks.json in your editor. It contains the JSON from Lesson 3.2. Now start the shell again:

Loaded 2 tasks from ./data/tasks.json
> list
1. [ ] Buy milk #shop
2. [x] Read book
> add Third
Added: 3. [ ] Third

Your tasks survived, and the new task gets ID 3.

Now break the file on purpose. Open data/tasks.json and change the first title to "". Start the shell:

error: load ./data/tasks.json: task 1: title is empty

The message shows every layer: which file, which task, and what is wrong. Fix the title and try again.

Why it works: errors carry their own story

Each function adds one short piece of context and passes the error up with %w. No function needs to know the whole story. The final message reads like a sentence, and code at the top can still use errors.Is to check the original cause.

Good context answers the question "What was I trying to do?" Use short phrases such as read task file or create folder, not "error while" or "failed to". Every message in the chain is an error, so those words add nothing.

Exercises

  1. Check the cause. In runShell, use errors.Is(err, task.ErrEmptyTitle) after store.Load fails. If it matches, print an extra hint: hint: open the file and give every task a title.
  2. Save on delete. If you added a delete command in an earlier exercise, make it save the file after a successful delete.
  3. A path command. Add a shell command path that prints the path of the task file.
  4. Count the layers. Use errors.Unwrap in a loop to print each layer of the error from Step 7, until it returns nil. How many layers are there?
  5. Make the folder read-only. On macOS or Linux, run chmod 555 data and add a task in the shell. Read the error. Then run chmod 755 data to fix it. Which line of Save failed?

Checkpoint

You are ready for the next lesson if you can:

  • Read and write files with os.ReadFile, os.CreateTemp, and os.Rename
  • Handle a missing file with errors.Is(err, os.ErrNotExist)
  • Explain the difference between %v and %w in fmt.Errorf
  • Explain when a deferred function call runs
  • Explain why Save writes to a temporary file first
  • Build paths with filepath.Join and read environment variables with os.Getenv