Saving to Disk
A Real Command-Line Tool
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
mainsmall with arunfunction 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
flagpackage - 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:
maindoes only three things. It callsrun, prints the error if there is one, and sets the exit code.- Every command returns an error instead of calling
os.Exititself. Error printing and exit codes now live in one place, so they are always consistent. runreceives its arguments as a parameter, instead of readingos.Argsitself. In Module 5, you can testrunby 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.ContinueOnErrortells the flag set to return an error instead of stopping the program. This keeps the rule from Step 2: onlymainexits.flags.Stringandflags.Booldefine flags. Each one takes a name, a default value, and a help text. They return a pointer. AfterParse, the pointer holds the value the user typed. That is why you read them with*tagand*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 returnsflag.ErrHelp. Asking for help is not a failure, sorunListreturnsnil.
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
- A
deletecommand. Addtasker delete <id>, using theDeletemethod from an earlier exercise. Follow the same shape asrunDone. - A
-doneflag. Add a-doneflag tolistthat shows only completed tasks. What should happen if the user passes both-openand-done? Return a clear error. - A
tagscommand. Addtasker tags, which prints the count for each tag, like the shell command. - Quiet mode. Add a
-qflag toadd. With-q, it prints only the new task's ID, for example4. Scripts can then use it:id=$(./tasker add -q Test) && ./tasker done $id. - 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
maincallsrunand why onlymaincallsos.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
