Checking lesson access...

Saving to Disk

A Real Command-Line Tool

Video and reading36 minPreview

You are viewing a free preview lesson.

Goal

By the end of this lesson, you can use Tasker directly from your terminal: tasker add, tasker list, and tasker done all work with the saved file. tasker list accepts flags such as -open, -tag, and -json. Tasker is now a tool you can really use every day and call from scripts.

What you will learn

  • How to keep main small with a run function that returns an error
  • How to report all errors in one place, with the correct exit code
  • How to parse command flags with the standard flag package
  • How the same code can serve two interfaces: the shell and one-shot commands
  • How to make a command-line tool work well in scripts and pipes

Why this matters

Until now, only the shell could use saved tasks. The one-shot commands from Module 1 were placeholders. Most command-line tools are used as one-shot commands, because they are fast to type and easy to combine with other tools. In this lesson, you connect them to the store. You also clean up the code, so that each command is a small function that returns an error.

Step 1: Add a Filter function

Both the shell and tasker list need to filter tasks, so the filter belongs in the task package. Add this to task/list.go, and add "slices" to its imports:

// Filter returns the tasks that have the tag (if tag is not empty)
// and that are still open (if openOnly is true).
func Filter(tasks []Task, tag string, openOnly bool) []Task {
	var result []Task
	for _, t := range tasks {
		if tag != "" && !slices.Contains(t.Tags, tag) {
			continue
		}
		if openOnly && t.Done {
			continue
		}
		result = append(result, t)
	}
	return result
}

Step 2: Make main tiny

Replace main.go:

package main

import (
	"fmt"
	"os"
)

const version = "0.1.0"

func main() {
	if err := run(os.Args[1:]); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}

func run(args []string) error {
	if len(args) == 0 {
		printUsage()
		return fmt.Errorf("missing command")
	}

	switch args[0] {
	case "add":
		return runAdd(args[1:])
	case "list":
		return runList(args[1:])
	case "done":
		return runDone(args[1:])
	case "shell":
		return runShell()
	case "version":
		fmt.Println("tasker", version)
		return nil
	case "help", "-h", "--help":
		printUsage()
		return nil
	default:
		printUsage()
		return fmt.Errorf("unknown command %q", args[0])
	}
}

func printUsage() {
	fmt.Fprintln(os.Stderr, `Usage: tasker <command> [arguments]

Commands:
  add <title> [#tag...]   Add a new task
  list [flags]            List tasks (run "tasker list -h" for flags)
  done <id>               Mark a task as done
  shell                   Start an interactive session
  version                 Print the version
  help                    Show this help

The task file is ~/.tasker/tasks.json. Set TASKER_FILE to use another file.`)
}

This is a very common pattern in Go programs:

  • main does only three things. It calls run, prints the error if there is one, and sets the exit code.
  • Every command returns an error instead of calling os.Exit itself. Error printing and exit codes now live in one place, so they are always consistent.
  • run receives its arguments as a parameter, instead of reading os.Args itself. In Module 5, you can test run by passing it any arguments you want, without starting a real program.

os.Exit stops the program immediately, and deferred functions do not run. Calling it only in main keeps the rest of the code safe to use defer.

Step 3: Write the commands

Create commands.go:

package main

import (
	"encoding/json"
	"errors"
	"flag"
	"fmt"

	"tasker/task"
)

// openStore finds the task file and loads it into a new store.
func openStore() (*task.Store, string, error) {
	path, err := dataPath()
	if err != nil {
		return nil, "", err
	}

	store := task.NewStore()
	if err := store.Load(path); err != nil {
		return nil, "", err
	}
	return store, path, nil
}

func runAdd(args []string) error {
	store, path, err := openStore()
	if err != nil {
		return err
	}

	title, tags := task.SplitTags(args)
	t, err := store.Add(title, tags)
	if err != nil {
		return err
	}
	if err := store.Save(path); err != nil {
		return err
	}

	fmt.Println("Added:", t)
	return nil
}

func runDone(args []string) error {
	if len(args) != 1 {
		return errors.New("done needs exactly one task ID")
	}
	id, err := task.ParseID(args[0])
	if err != nil {
		return err
	}

	store, path, err := openStore()
	if err != nil {
		return err
	}

	t, err := store.Complete(id)
	if err != nil {
		return fmt.Errorf("task %d: %w", id, err)
	}
	if err := store.Save(path); err != nil {
		return err
	}

	fmt.Println("Completed:", t)
	return nil
}

Each command follows the same shape: check the arguments, open the store, do one change, save, and print the result.

runDone checks the arguments before it opens the store. When the input is wrong, there is no reason to read the file. Validate cheap things first.

openStore returns three values: the store, the path, and an error. When it fails, it returns nil for the pointer and "" for the path. The caller must check err before it uses the other values, as always.

Step 4: Parse flags with the flag package

Flags are named options such as -open or -tag shop. The standard library has a package for them. Add runList to commands.go:

func runList(args []string) error {
	flags := flag.NewFlagSet("list", flag.ContinueOnError)
	tag := flags.String("tag", "", "show only tasks with this `tag`")
	openOnly := flags.Bool("open", false, "show only open tasks")
	asJSON := flags.Bool("json", false, "print tasks as JSON")

	err := flags.Parse(args)
	if errors.Is(err, flag.ErrHelp) {
		return nil
	}
	if err != nil {
		return err
	}

	store, _, err := openStore()
	if err != nil {
		return err
	}

	tasks := task.Filter(store.List(), *tag, *openOnly)

	if *asJSON {
		data, err := json.MarshalIndent(tasks, "", "  ")
		if err != nil {
			return err
		}
		fmt.Println(string(data))
		return nil
	}

	for _, t := range tasks {
		fmt.Println(t)
	}
	return nil
}
  • flag.NewFlagSet("list", ...) creates a set of flags for one command. Each command can have its own flags.
  • flag.ContinueOnError tells the flag set to return an error instead of stopping the program. This keeps the rule from Step 2: only main exits.
  • flags.String and flags.Bool define flags. Each one takes a name, a default value, and a help text. They return a pointer. After Parse, the pointer holds the value the user typed. That is why you read them with *tag and *openOnly.
  • The backticks around `tag` in the help text tell the flag package what to call the value in the help output.
  • flags.Parse(args) reads the flags. When the user types -h, it prints the help and returns flag.ErrHelp. Asking for help is not a failure, so runList returns nil.

In Module 4, Cobra replaces this code. Cobra uses the same ideas, with more features and less code per command.

Step 5: Update the shell

The shell can now use openStore and Filter too. Change the start of runShell so it returns an error:

func runShell() error {
	store, path, err := openStore()
	if err != nil {
		return err
	}
	fmt.Printf("Loaded %d tasks from %s\n", store.Len(), path)

Replace the list case:

		case "list":
			tag := ""
			if len(words) == 2 {
				tag = strings.TrimPrefix(strings.ToLower(words[1]), "#")
			}
			tasks := task.Filter(store.List(), tag, false)
			if len(tasks) == 0 {
				fmt.Println("No tasks found.")
			}
			for _, t := range tasks {
				fmt.Println(t)
			}

In the quit case, change return to return nil. At the very end of the function, after the for loop, add:

	return scanner.Err()

The loop also ends when the input ends, for example with Ctrl+D. scanner.Err() returns nil in that normal case, and an error if reading the input failed.

Step 6: Use Tasker from the terminal

Build and try it, with a test file:

go build
export TASKER_FILE=./data/tasks.json
./tasker add Buy milk '#shop'
./tasker add Fix bike '#home'
./tasker add Pay rent
./tasker done 2
./tasker list

Output:

Added: 1. [ ] Buy milk #shop
Added: 2. [ ] Fix bike #home
Added: 3. [ ] Pay rent
Completed: 2. [x] Fix bike #home
1. [ ] Buy milk #shop
2. [x] Fix bike #home
3. [ ] Pay rent

Try the flags:

./tasker list -open
./tasker list -tag shop -json
./tasker list -h

-open shows tasks 1 and 3. -tag shop -json prints task 1 as JSON. -h prints this help, generated by the flag package:

Usage of list:
  -json
    	print tasks as JSON
  -open
    	show only open tasks
  -tag tag
    	show only tasks with this tag

Try some errors:

./tasker done 2
./tasker done 9
error: task 2: task is already done
error: task 9: task not found

Both exit with code 1.

Step 7: Tasker in scripts

Because Tasker follows the rules from Lesson 1.4 (results to stdout, errors to stderr, and correct exit codes), it works together with other tools:

./tasker list | grep -c '\[ \]'

This counts your open tasks. grep -c counts the lines that contain [ ].

./tasker add Call Ana && ./tasker list -open

&& runs the second command only if the first one succeeds, which means exit code 0.

./tasker list -json | jq '.[].title'

If you have the jq tool installed, it reads the JSON and prints only the titles. The -json flag makes Tasker easy to use from other programs and from scripts in any language.

Why it works: one core, many interfaces

Look at what changed in this module. The task package now holds all the rules: validation, IDs, completing tasks, filtering, JSON, and files. The main package contains only interfaces to the user: the shell and the one-shot commands. Both are thin layers that call the same core.

Part 2 adds one more interface: an HTTP API. Because the core is separate, the API reuses the same ideas instead of rewriting them.

Exercises

  1. A delete command. Add tasker delete <id>, using the Delete method from an earlier exercise. Follow the same shape as runDone.
  2. A -done flag. Add a -done flag to list that shows only completed tasks. What should happen if the user passes both -open and -done? Return a clear error.
  3. A tags command. Add tasker tags, which prints the count for each tag, like the shell command.
  4. Quiet mode. Add a -q flag to add. With -q, it prints only the new task's ID, for example 4. Scripts can then use it: id=$(./tasker add -q Test) && ./tasker done $id.
  5. Check the exit codes. Write down the exit code for each command in Step 6. Then check them with echo $?.

Checkpoint

You have finished Module 3 if you can:

  • Explain why main calls run and why only main calls os.Exit
  • Write a command function that returns an error
  • Define and read flags with flag.NewFlagSet
  • Explain why flag functions return pointers
  • Use Tasker in a pipe or a script, and explain why stdout, stderr, and exit codes matter