# Build a Real CLI Tool in Go

> Build til, a note-taking CLI in Go - subcommands, flags, safe JSON storage on disk, aligned table output, real tests, and cross-compiled binaries you can put on your PATH.


---

# Build a Real CLI Tool in Go

You learn things at work every day - a Git flag that saved you, a Go quirk that cost you an hour - and by Friday they're gone. This project fixes that with a tool you'll actually keep using: **`til`**, a "today I learned" log that lives in your terminal. Type `til add "defer runs in LIFO order"` the moment you learn something; months later, `til search defer` hands it back.

Along the way you'll learn the skills that every real CLI is made of: subcommands, flags, state that survives between runs, output that's pleasant to read, tests that catch regressions, and shipping a binary that runs on machines that have never heard of Go.

This one runs **on your machine.** You'll compile real binaries, keep state in a real file in your home directory, and end with `til` on your PATH like any other command you use.

## Why Go for this

You could write a CLI in Python or Node. People do. Then they try to give it to a teammate and hit the wall: "first install Python 3.12, then make a venv, then pip install..." A Go CLI is different in one way that changes everything:

**`go build` produces one self-contained binary.** No runtime to install, no dependency folder, no version manager. You copy a single file to another machine - even a different operating system, cross-compiled from yours - and it runs. That, plus instant startup and a standard library that covers flags, JSON, and testing without a single external package, is why so many tools you already use (Docker, kubectl, gh, terraform) are written in Go.

```mermaid
flowchart LR
  A[main.go] --> B[go build]
  B --> C[til - one binary]
  C --> D[your machine]
  C --> E[teammate's Mac]
  C --> F[a Linux server]
```

## The stack

| Piece | What it is | Why we use it |
|-------|-----------|---------------|
| Go standard library | `flag`, `encoding/json`, `text/tabwriter`, `testing` | Everything a CLI needs, zero dependencies |
| A JSON file | `~/.til/notes.json` | State that survives between runs, readable with any editor |
| `go build` / `go install` | The Go toolchain | One-command compile, one-command install to PATH |

That's the whole list. This project has **zero third-party dependencies** - the `go.mod` file will never grow past two lines.

## What you'll need

- **Go 1.22 or newer.** Check with `go version` in a terminal. If you don't have it, grab the installer from go.dev/dl.
- A text editor and a terminal.
- The basics from a first pass at Go - variables, structs, slices, functions, and `if err != nil`. If you've finished the *Go from Zero* guide, you're ready.

Rough time: a focused day, or two relaxed evenings.

## What you'll learn

- How a CLI reads its arguments, and how subcommands like `git commit` actually work
- The `flag` package and per-subcommand flag sets
- Persisting state as JSON without corrupting it, even if the program dies mid-write
- Aligned table output with `text/tabwriter`
- Table-driven tests with the `testing` package and `t.TempDir()`
- Cross-compiling for Windows, macOS, and Linux, and installing your tool on PATH

## The phases

1. **Why Go, and a Compiling Project** - set up the module, read `os.Args`, and build your first binary.
2. **Flags and the add Command** - subcommand dispatch and the `flag` package, done the way real tools do it.
3. **JSON on Disk, Safely** - notes that survive between runs, written so a crash can't corrupt them.
4. **list, search, and tags** - the remaining subcommands, with clean table output.
5. **Tests That Catch Real Bugs** - table-driven tests for the logic, temp dirs for the file handling.
6. **Cross-Compile and Ship It** - binaries for three operating systems, and `til` on your PATH for good.

Each phase ends with a program that compiles and runs. By phase 3 you're already storing real notes; by phase 6 the tool is installed and part of your day. Let's set it up.


---

# Why Go, and a Compiling Project

Every phase of this project ends with a program you can run. This first one is short on features and long on foundation: a Go module, a `main.go` that can see its own command-line arguments, and a compiled binary sitting in your folder. Once you've watched `go build` turn source code into a single executable file, the rest of the project is filling that file with behavior.

Open a terminal. Let's make the project.

## Check your Go

First, confirm the toolchain is there:

```console
$ go version
go version go1.24.4 windows/amd64
```

*What just happened:* Go printed its version and your platform - the `windows/amd64` part is your operating system and CPU architecture, a pair you'll meet again in phase 6 when you cross-compile for *other* platforms. Your version and platform will differ from mine; anything 1.22 or newer is fine. If the command isn't found, install Go from go.dev/dl and reopen your terminal.

## Create the module

Make a folder and initialize a module in it:

```console
$ mkdir til
$ cd til
$ go mod init til
go: creating new go.mod: module til
```

*What just happened:* `go mod init til` created a file called `go.mod` that declares "this folder is a Go module named `til`." The module name is how Go identifies your project - for a library you'd use a full path like `github.com/you/til` so others can import it, but for a personal tool a short name works. Open the file and look:

```text
module til

go 1.24.4
```

That's the entire project configuration. When a project has dependencies, they're listed here too - ours never will, because everything we need ships with Go.

📝 **Terminology:** a **module** is Go's unit of versioning and dependency tracking (the folder with `go.mod`); a **package** is a folder of `.go` files that compile together. Our module contains exactly one package: `main`, the special package name that means "this compiles to an executable."

## The first main.go

Create `main.go` next to `go.mod`:

```go
package main

import (
	"fmt"
	"os"
)

func main() {
	fmt.Println("til - a tiny \"today I learned\" log")
	fmt.Println("you passed:", os.Args[1:])
}
```

Two lines of real content, and one of them is the key to this whole project.

**`os.Args` is the raw command line.** It's a slice of strings holding everything the user typed, split on spaces. Index 0 is the program's own name; everything after that is what the user passed. Every CLI you've ever used - `git`, `docker`, `npm` - starts from exactly this slice. There's no magic underneath; subcommands and flags are all parsed out of these strings, and in the next phase you'll do that parsing yourself.

We print `os.Args[1:]` - everything *except* the program name - to see what arrives.

## Run it

`go run` compiles and runs in one step, which is the fast loop you'll use while developing:

```console
$ go run . add "my first note"
til - a tiny "today I learned" log
you passed: [add my first note]
```

*What just happened:* the `.` means "the package in the current folder." Go compiled `main.go` to a temporary binary, ran it, and passed along everything after the `.`. Notice what the slice looks like: `add` and `my first note` arrived as **two** elements, not four - your shell kept the quoted string together before Go ever saw it. That's why you quote multi-word arguments, and it's why `os.Args` is a `[]string` and not one long string.

⚠️ **Gotcha:** run `go run . add my first note` without the quotes and you'll get `[add my first note]` printed the same way - but it's now *four* elements, and later phases would treat `my` as the note and lose the rest. The shell splits on spaces; quotes are how you tell it not to. If a note ever comes out truncated, this is the first thing to check.

## Build the binary

`go run` is for development. The real artifact comes from:

```console
$ go build
$ ls
go.mod  main.go  til.exe
```

*What just happened:* `go build` compiled the package into an executable named after the module - `til.exe` on Windows, plain `til` on macOS and Linux. No output on success is normal; Go is quiet when things work.

Run it directly:

```console
$ ./til add "hello"
til - a tiny "today I learned" log
you passed: [add hello]
```

(On Windows PowerShell that's `.\til add "hello"` - the `./` or `.\` prefix means "the one in this folder," since the folder isn't on your PATH. Yet. Phase 6 fixes that.)

That file is the entire program. It's a few megabytes because Go packs the runtime and every library the program uses inside it - and that's the trade Go makes deliberately: a bigger file, in exchange for **no installation step ever**. You could email `til.exe` to a colleague on Windows right now and it would run. No Go toolchain, no dependencies, nothing to set up. Compare that with shipping a Python script, and you understand why so much command-line tooling moved to Go.

💡 **Key point:** `go run .` while developing, `go build` when you want the artifact. Both compile the same way; the only difference is whether the binary sticks around.

## What you have now

A Go module, a program that can read its command line, and proof that it compiles to a single self-contained binary. It doesn't *do* anything with `add` yet - `os.Args` is still raw, unparsed text. Next phase we give those strings meaning: a real subcommand dispatcher and the `flag` package, which is the skeleton every serious CLI hangs off.

Quick check before you move on:

```quiz
[
  {
    "q": "What does go build produce for a CLI project like this one?",
    "choices": [
      "A folder of compiled files plus a runtime the target machine must install",
      "One self-contained executable that runs without Go installed",
      "Bytecode that needs the Go toolchain present to execute"
    ],
    "answer": 1,
    "explain": "Go statically compiles your code, its dependencies, and the runtime into a single binary. The machine running it never needs Go."
  },
  {
    "q": "What is os.Args[0]?",
    "choices": [
      "The first argument the user typed after the program name",
      "The name/path of the program itself",
      "Always an empty string"
    ],
    "answer": 1,
    "explain": "Index 0 is the program's own name; user-supplied arguments start at index 1. That's why we print os.Args[1:]."
  },
  {
    "q": "You run: go run . add my first note (no quotes). How does the note text arrive?",
    "choices": [
      "As one string, because Go rejoins the words",
      "As three separate elements, because the shell split on spaces",
      "It causes a compile error"
    ],
    "answer": 1,
    "explain": "The shell splits unquoted input on whitespace before the program starts. Quoting keeps multi-word arguments as a single element."
  }
]
```


---

# Flags and the add Command

Type `git commit -m "fix"` and three different kinds of thing cross the command line: a **subcommand** (`commit`), a **flag** (`-m "fix"`), and the program figures out which is which. This phase builds that exact machinery for `til`. By the end, `til add -tags go "defer runs in LIFO order"` will parse cleanly into a structured note - it won't be *saved* yet (that's phase 3), but every argument will land in the right field.

## How subcommands actually work

There's no subcommand feature in Go, and there isn't one in C or Rust either. A subcommand is nothing more than a convention: **look at `os.Args[1]` and switch on it.** When you type `git commit`, git reads `"commit"` from its argument list and jumps to its commit code. That's the whole trick.

```mermaid
flowchart LR
  A["os.Args[1]"] --> B{switch}
  B -- "add" --> C[runAdd]
  B -- "list" --> D[runList - later]
  B -- anything else --> E[print usage, exit 1]
```

Which leaves the flags. You *could* parse `-tags go,cli` by hand out of the string slice, and people did for decades - it's fiddly and everyone's hand-rolled version disagrees about `-tags=go` vs `-tags go`. Go's standard `flag` package handles the fiddly part.

**One wrinkle:** the package-level `flag.Parse()` you may have seen in tutorials defines one global set of flags for the whole program. Subcommands need better than that - `add` has a `-tags` flag, and a future `list` will have `-n`, and they shouldn't collide or show up in each other's help text. The tool for that is **`flag.NewFlagSet`**: an independent, named collection of flags, one per subcommand. This is the same pattern the Go team's own tools use.

## The Note type

A note has an ID, the text, optional tags, and a timestamp. Define it as a struct - this type is the heart of the program and every later phase builds on it:

```go
type Note struct {
	ID      int
	Text    string
	Tags    []string
	Created time.Time
}
```

📝 **Terminology:** `time.Time` is Go's timestamp type. `time.Now()` gives you the current moment, and in phase 4 we'll format it for display with `.Format`.

## Your turn: parseTags

Before the full file, here's one piece to write yourself. Everything else in this phase introduces new API (`flag.NewFlagSet`, the dispatch switch), but this one you can already do with what you know.

`parseTags` takes the raw `-tags` string and turns it into a clean slice. `"Go, CLI"` should come back as `["go", "cli"]`: split on commas, trim the spaces, lowercase it, and drop anything empty, so `"go,,cli"` gives two tags rather than three. An empty string gives `nil`.

```go
// parseTags turns "Go, CLI" into ["go", "cli"].
func parseTags(s string) []string {
	// your turn
	return nil
}
```

`strings.Split`, `strings.TrimSpace` and `strings.ToLower` are all you need. My version is in the file below, and once the program runs you can check it for real: `go run . add -tags "Go, CLI" "a note"` should show both tags, lowercased and trimmed.

## The full main.go

Replace everything in `main.go` with this - including my `parseTags`, so compare it with yours. Read it top to bottom afterward: every piece gets explained.

```go
package main

import (
	"errors"
	"flag"
	"fmt"
	"os"
	"strings"
	"time"
)

type Note struct {
	ID      int
	Text    string
	Tags    []string
	Created time.Time
}

// parseTags turns "Go, CLI" into ["go", "cli"].
func parseTags(s string) []string {
	if s == "" {
		return nil
	}
	var tags []string
	for _, p := range strings.Split(s, ",") {
		p = strings.TrimSpace(strings.ToLower(p))
		if p != "" {
			tags = append(tags, p)
		}
	}
	return tags
}

func runAdd(args []string) error {
	fs := flag.NewFlagSet("add", flag.ExitOnError)
	tags := fs.String("tags", "", "comma-separated tags, e.g. -tags go,cli")
	fs.Parse(args)

	text := strings.TrimSpace(strings.Join(fs.Args(), " "))
	if text == "" {
		return errors.New(`nothing to add - usage: til add [-tags a,b] "your note"`)
	}

	note := Note{ID: 1, Text: text, Tags: parseTags(*tags), Created: time.Now()}
	fmt.Printf("Would add: %+v\n", note)
	return nil
}

func usage() {
	fmt.Println(`til - a tiny "today I learned" log

Usage:
  til add [-tags a,b] "your note"`)
}

func main() {
	if len(os.Args) < 2 {
		usage()
		os.Exit(1)
	}

	var err error
	switch os.Args[1] {
	case "add":
		err = runAdd(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "unknown command %q\n", os.Args[1])
		usage()
		os.Exit(1)
	}
	if err != nil {
		fmt.Fprintln(os.Stderr, "til:", err)
		os.Exit(1)
	}
}
```

Now the walkthrough, in the order things happen at runtime.

**`main` dispatches.** No arguments at all? Print usage and exit with status 1 - the universal "something went wrong" exit code that shells and scripts check. Otherwise, switch on `os.Args[1]` and hand the *rest* of the arguments - `os.Args[2:]`, everything after the subcommand - to the matching function. Each `runX` function returns an `error` instead of exiting on its own; `main` is the single place that turns errors into an exit code. Keeping exits in one place pays off in phase 5, when we test these functions - a function that calls `os.Exit` kills the test process too.

**`flag.NewFlagSet("add", flag.ExitOnError)`** creates the flag collection for this subcommand only. The name `"add"` appears in its error messages; `flag.ExitOnError` means "if the user passes a malformed flag, print the problem and exit" - the right behavior for a CLI, and it's also why we don't need to check `fs.Parse`'s return value.

**`fs.String("tags", "", "…")`** declares a flag: name, default value, help text. It returns a `*string` - a pointer - because the value doesn't exist until `Parse` runs; you read it afterward with `*tags`. The flag package accepts `-tags go,cli`, `-tags=go,cli`, and `--tags go,cli` interchangeably.

**`fs.Args()`** is what's left over after the flags are consumed - the positional arguments. For us, that's the note text. We `strings.Join` them with spaces so both `til add "one quoted note"` and `til add one unquoted note` produce the same text.

⚠️ **Gotcha - flags come before the text.** `fs.Parse` reads arguments left to right and **stops at the first thing that isn't a flag**. So `til add -tags go "my note"` works, but `til add "my note" -tags go` treats `-tags` and `go` as part of the note text, and your tag silently becomes words in the note. Every Go CLI built on the standard `flag` package shares this behavior (git behaves differently, which is exactly why this bites people). We'll write the usage string to show flags first, and now you know why.

**The error path goes to `os.Stderr`.** Normal output goes to stdout, errors to stderr. That split matters in real use: when someone pipes your tool - `til list | grep go` - error messages still reach their screen instead of disappearing into the pipe.

## Run it

```console
$ go run . add -tags go "defer runs in LIFO order"
Would add: {ID:1 Text:defer runs in LIFO order Tags:[go] Created:2026-07-06 14:22:31.4507118 +0200 CEST m=+0.000512301}
```

*What just happened:* `-tags go` was consumed by the flag set, the remaining words became the note text, and `%+v` printed the struct with field names. That trailing `m=+0.000512301` on the timestamp is Go's monotonic clock reading - internal bookkeeping that comes along when you print a `time.Time` raw. It disappears once we store and format times properly.

Try the failure paths too - a good CLI is defined by how it fails:

```console
$ go run . add
til: nothing to add - usage: til add [-tags a,b] "your note"
$ go run . remove 3
unknown command "remove"
til - a tiny "today I learned" log

Usage:
  til add [-tags a,b] "your note"
```

*What just happened:* both runs exited with status 1 and wrote to stderr. An empty `add` was caught by our own check; an unknown subcommand fell through to `default`. Nothing panicked, nothing printed a stack trace at the user - errors are sentences.

## What you have now

A real CLI skeleton: subcommand dispatch, per-command flags, positional arguments, and errors that behave like a grown-up tool's. What it doesn't have is memory - `Would add:` is an admission that the note goes nowhere. Next phase gives `til` a place to keep things: a JSON file in your home directory, written carefully enough that a crash mid-save can't destroy your notes.

Two quick questions to lock in the parsing model:

```quiz
[
  {
    "q": "Why must flags come before the note text, as in: til add -tags go \"my note\"?",
    "choices": [
      "The flag package requires flags in alphabetical order",
      "FlagSet.Parse stops treating arguments as flags at the first non-flag argument",
      "The shell reorders arguments before Go sees them"
    ],
    "answer": 1,
    "explain": "Parse consumes flags left to right and stops at the first positional argument - anything after that is left for fs.Args(), even if it looks like a flag."
  },
  {
    "q": "What does flag.NewFlagSet give you that the package-level flag functions don't?",
    "choices": [
      "Faster parsing of large argument lists",
      "An independent set of flags per subcommand, so add and list can't collide",
      "Automatic generation of subcommands from struct fields"
    ],
    "answer": 1,
    "explain": "The package-level flag.Parse manages one global flag set. NewFlagSet creates a named, isolated set - one per subcommand is the standard pattern."
  },
  {
    "q": "Why does runAdd return an error instead of calling os.Exit itself?",
    "choices": [
      "os.Exit is not allowed outside package main",
      "So main is the single place that maps errors to exit codes - and the function stays testable",
      "Returning an error is faster than exiting"
    ],
    "answer": 1,
    "explain": "A function that calls os.Exit kills any test that calls it. Return errors upward; let main decide the exit code."
  }
]
```


---

# JSON on Disk, Safely

Right now `til` has amnesia - every run starts from nothing. This phase gives it memory: a JSON file in your home directory that `add` appends to and a new `list` command reads back. The interesting part isn't reading and writing JSON - Go makes that almost free - it's doing it in a way that can't eat your notes. A note-taking tool that corrupts its own store the one time your laptop dies mid-save is worse than no tool at all, so we'll write the save path the way real tools do.

## Where state lives

CLI tools keep their state in a dotfolder in your home directory - that's the convention behind `~/.gitconfig`, `~/.ssh`, `~/.docker`. We'll follow it: notes go in `~/.til/notes.json`. The reader-visible win: it's one JSON file you can open, read, and back up with anything.

Go hands you the home directory portably:

```go
// storePath returns the notes file location: ~/.til/notes.json.
func storePath() (string, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return "", fmt.Errorf("finding home directory: %w", err)
	}
	return filepath.Join(home, ".til", "notes.json"), nil
}
```

`os.UserHomeDir` returns `C:\Users\you` on Windows and `/home/you` on Linux, and `filepath.Join` uses the right slashes for the platform - two functions, and the path question is closed on every OS. Note that the path comes back as a return value instead of living in a global: every function that touches the store will *take the path as a parameter*. That looks like a small style choice today; in phase 5 it's the whole reason the storage code is testable.

## Teaching the struct to speak JSON

`encoding/json` converts between Go structs and JSON text. By default it uses the Go field names - `Text`, `Created` - but JSON convention is lowercase. **Struct tags** fix the mapping:

```go
type Note struct {
	ID      int       `json:"id"`
	Text    string    `json:"text"`
	Tags    []string  `json:"tags,omitempty"`
	Created time.Time `json:"created"`
}
```

📝 **Terminology:** the backtick string after a field is a **struct tag** - metadata that packages like `encoding/json` read to decide how to handle the field. `json:"text"` says "call this key `text` in the JSON." The extra `omitempty` on `Tags` says "if the slice is empty, leave the key out entirely" - an untagged note stores as two lines shorter, and it's why `parseTags` returning `nil` for no tags was fine.

⚠️ **Gotcha:** struct tags only apply to **exported** fields (capitalized names). Rename a field to `text` and `encoding/json` skips it silently - no error, the data is simply absent from the file. If a field ever mysteriously stops persisting, check its capitalization first.

## Loading: a missing file is not an error

```go
// loadNotes reads the store. A missing file is a first run, not an error.
func loadNotes(path string) ([]Note, error) {
	data, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		return []Note{}, nil
	}
	if err != nil {
		return nil, err
	}
	var notes []Note
	if err := json.Unmarshal(data, &notes); err != nil {
		return nil, fmt.Errorf("parsing %s: %w", path, err)
	}
	return notes, nil
}
```

The first `if` is the line that makes the tool pleasant. On a brand-new machine, `notes.json` doesn't exist - and that's not a failure, it's day one. `errors.Is(err, os.ErrNotExist)` asks "is this error, or anything it wraps, a file-not-found?" and if so we return an empty list and move on. Every other error - permissions, a disk problem, JSON someone hand-edited into invalidity - is real and gets reported, with the file path wrapped in so the user knows *which* file to look at.

## Saving: the atomic write

Here's the scenario that separates careful tools from careless ones. You have 200 notes. `til` opens `notes.json`, starts writing the new version, and halfway through - power cut, `kill -9`, out-of-battery laptop lid snap. The file now contains half a JSON array. Next run, `loadNotes` fails to parse it. All 200 notes are hostage to a text file that ends mid-sentence.

The fix is old, small, and standard: **never write onto your only copy.** Write the new version to a temporary file next to the real one, and only when it's fully on disk, rename it over the original. A rename within the same directory is a single atomic operation at the filesystem level - it either fully happens or doesn't happen at all. There is no moment where `notes.json` is half-written.

```mermaid
flowchart LR
  A[notes in memory] --> B[write notes.json.tmp]
  B --> C{write completed?}
  C -- yes --> D[rename over notes.json]
  C -- "crash mid-write" --> E[notes.json untouched]
```

```go
// saveNotes writes the store atomically: temp file first, then rename.
func saveNotes(path string, notes []Note) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	data, err := json.MarshalIndent(notes, "", "  ")
	if err != nil {
		return err
	}
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, data, 0o644); err != nil {
		return err
	}
	return os.Rename(tmp, path)
}
```

Reading it through:

- **`os.MkdirAll`** creates `~/.til` if it's missing (and does nothing if it exists) - without this, the very first save fails because the folder isn't there. The `0o755` is the Unix permission bits (owner can write, everyone can read); Windows largely ignores them.
- **`json.MarshalIndent(notes, "", "  ")`** serializes the whole slice with two-space indentation. Plain `json.Marshal` would produce one long line; indented output means you can open your own store and read it, which you will, the first time you're curious.
- **Write `.tmp`, then `os.Rename`.** The two-step described above. If the process dies during `WriteFile`, the damage is a stray `notes.json.tmp` - your real store never changed.

💡 **Key point:** load tolerates absence, save tolerates interruption. Those two decisions are what "storing state safely" means, and they cost eleven lines total.

## Wiring it into add and list

`runAdd` now loads, appends, and saves. The new note's ID is the last note's ID plus one - IDs only ever grow, so they stay stable even if you later add a delete command. And `runList` is new - minimal for now, a proper table comes next phase.

Here's the complete `main.go` for this phase:

```go
package main

import (
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"
)

type Note struct {
	ID      int       `json:"id"`
	Text    string    `json:"text"`
	Tags    []string  `json:"tags,omitempty"`
	Created time.Time `json:"created"`
}

// parseTags turns "Go, CLI" into ["go", "cli"].
func parseTags(s string) []string {
	if s == "" {
		return nil
	}
	var tags []string
	for _, p := range strings.Split(s, ",") {
		p = strings.TrimSpace(strings.ToLower(p))
		if p != "" {
			tags = append(tags, p)
		}
	}
	return tags
}

// storePath returns the notes file location: ~/.til/notes.json.
func storePath() (string, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return "", fmt.Errorf("finding home directory: %w", err)
	}
	return filepath.Join(home, ".til", "notes.json"), nil
}

// loadNotes reads the store. A missing file is a first run, not an error.
func loadNotes(path string) ([]Note, error) {
	data, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		return []Note{}, nil
	}
	if err != nil {
		return nil, err
	}
	var notes []Note
	if err := json.Unmarshal(data, &notes); err != nil {
		return nil, fmt.Errorf("parsing %s: %w", path, err)
	}
	return notes, nil
}

// saveNotes writes the store atomically: temp file first, then rename.
func saveNotes(path string, notes []Note) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	data, err := json.MarshalIndent(notes, "", "  ")
	if err != nil {
		return err
	}
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, data, 0o644); err != nil {
		return err
	}
	return os.Rename(tmp, path)
}

func runAdd(args []string) error {
	fs := flag.NewFlagSet("add", flag.ExitOnError)
	tags := fs.String("tags", "", "comma-separated tags, e.g. -tags go,cli")
	fs.Parse(args)

	text := strings.TrimSpace(strings.Join(fs.Args(), " "))
	if text == "" {
		return errors.New(`nothing to add - usage: til add [-tags a,b] "your note"`)
	}

	path, err := storePath()
	if err != nil {
		return err
	}
	notes, err := loadNotes(path)
	if err != nil {
		return err
	}

	id := 1
	if len(notes) > 0 {
		id = notes[len(notes)-1].ID + 1
	}
	notes = append(notes, Note{ID: id, Text: text, Tags: parseTags(*tags), Created: time.Now()})

	if err := saveNotes(path, notes); err != nil {
		return err
	}
	fmt.Printf("Added note #%d\n", id)
	return nil
}

func runList() error {
	path, err := storePath()
	if err != nil {
		return err
	}
	notes, err := loadNotes(path)
	if err != nil {
		return err
	}
	if len(notes) == 0 {
		fmt.Println(`No notes yet. Add one: til add "what you learned"`)
		return nil
	}
	for _, n := range notes {
		fmt.Printf("#%d [%s] %s\n", n.ID, n.Created.Format("2006-01-02"), n.Text)
	}
	return nil
}

func usage() {
	fmt.Println(`til - a tiny "today I learned" log

Usage:
  til add [-tags a,b] "your note"
  til list`)
}

func main() {
	if len(os.Args) < 2 {
		usage()
		os.Exit(1)
	}

	var err error
	switch os.Args[1] {
	case "add":
		err = runAdd(os.Args[2:])
	case "list":
		err = runList()
	default:
		fmt.Fprintf(os.Stderr, "unknown command %q\n", os.Args[1])
		usage()
		os.Exit(1)
	}
	if err != nil {
		fmt.Fprintln(os.Stderr, "til:", err)
		os.Exit(1)
	}
}
```

One new thing hides in `runList`: the date format. Go doesn't use `YYYY-MM-DD` patterns - you format times by writing **the reference moment**, `Mon Jan 2 15:04:05 MST 2006`, in the layout you want. `n.Created.Format("2006-01-02")` means "year-month-day, like 2006-01-02." It confuses *everybody* the first time; the mnemonic is that the reference date's parts count 1 2 3 4 5 6 in US order (month 1, day 2, hour 3, minute 4, second 5, year 6).

## Run it

```console
$ go run . add -tags go "defer runs in LIFO order"
Added note #1
$ go run . add -tags go,cli "flag.NewFlagSet gives each subcommand its own flags"
Added note #2
$ go run . list
#1 [2026-07-06] defer runs in LIFO order
#2 [2026-07-06] flag.NewFlagSet gives each subcommand its own flags
```

*What just happened:* two `add` runs each loaded the store, appended, and saved atomically; `list` read the result back. The notes survived across three separate processes - `til` has memory now.

Go look at the memory. Open `~/.til/notes.json` (on Windows, `C:\Users\you\.til\notes.json`):

```json
[
  {
    "id": 1,
    "text": "defer runs in LIFO order",
    "tags": [
      "go"
    ],
    "created": "2026-07-06T14:31:08.2214563+02:00"
  },
  {
    "id": 2,
    "text": "flag.NewFlagSet gives each subcommand its own flags",
    "tags": [
      "go",
      "cli"
    ],
    "created": "2026-07-06T14:31:52.9083317+02:00"
  }
]
```

*What just happened:* every struct tag did its job - lowercase keys, and `time.Time` serialized itself as an RFC 3339 timestamp (timezone included) that it can parse back losslessly. Your store is plain text you could edit, back up, or sync between machines.

## What you have now

A CLI with durable, crash-safe state and stable note IDs. The `list` output is plain but flat - and there's no way yet to *find* anything, which for a memory tool is the entire point. Next phase: `list` grows flags and a real table, and `search` and `tags` join the roster.

Lock in the storage ideas before moving on:

```quiz
[
  {
    "q": "Why does saveNotes write to notes.json.tmp and then rename, instead of writing notes.json directly?",
    "choices": [
      "It is faster than writing the file directly",
      "So two til processes can safely write at the same time",
      "A crash mid-write can only leave a broken .tmp file - the real notes.json is never half-written"
    ],
    "answer": 2,
    "explain": "The rename is atomic, so the store flips from old version to new version with no in-between state. (Two processes writing at once is a different problem - the rename trick does not solve that.)"
  },
  {
    "q": "What does loadNotes do when notes.json does not exist?",
    "choices": [
      "Returns an error so the user knows something is wrong",
      "Returns an empty list - a first run is not an error",
      "Creates the file immediately with an empty array"
    ],
    "answer": 1,
    "explain": "errors.Is(err, os.ErrNotExist) catches file-not-found and treats it as day one. The file gets created by the first save instead."
  },
  {
    "q": "What does the struct tag json:\"text\" on the Text field do?",
    "choices": [
      "Controls the key name used when the struct is marshaled to JSON",
      "Renames the field everywhere in Go code",
      "Validates that the field is non-empty before saving"
    ],
    "answer": 0,
    "explain": "Struct tags are metadata for packages like encoding/json. Without the tag, the JSON key would be the Go field name, Text."
  }
]
```


---

# list, search, and tags

A note you can't find again was never really saved. This phase turns `til` from a log into a memory: `list` learns flags for "the last few" and "only this tag," `search` finds that thing you half-remember from March, and `tags` shows you the shape of what you've been learning. Along the way you'll meet `text/tabwriter`, the standard library's answer to "how do real CLIs print those neat aligned columns?"

One design rule carries this whole phase: **the functions that decide are separate from the functions that print.** `searchNotes` takes notes and returns notes; something else formats them. It reads like fussiness today - in phase 5 it's what makes the logic testable without capturing terminal output.

## Table output with tabwriter

Print notes with plain `Printf` and columns wander - a 2-digit ID pushes its row a character right of a 1-digit one, tags of different lengths shove the text around. Fixed-width format strings like `%-20s` fix alignment but truncate or overflow the moment real data shows up.

`text/tabwriter` solves it properly: you write rows with tab characters (`\t`) between cells, it **buffers everything**, measures the widest cell in each column, and pads every cell to match when you call `Flush`. Columns fit the data, whatever the data is.

```go
// printTable renders notes as an aligned table.
func printTable(notes []Note) {
	if len(notes) == 0 {
		fmt.Println(`No notes yet. Add one: til add "what you learned"`)
		return
	}
	w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
	fmt.Fprintln(w, "ID\tDATE\tTAGS\tNOTE")
	for _, n := range notes {
		fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", n.ID, n.Created.Format("2006-01-02"), strings.Join(n.Tags, ","), n.Text)
	}
	w.Flush()
}
```

The `NewWriter` arguments in order: the destination, minimum cell width (0), tab width (4, unused with spaces), padding between columns (2 spaces), the padding character, and flags (none). You rarely change these.

⚠️ **Gotcha:** because tabwriter buffers, **forgetting `w.Flush()` prints nothing at all.** No error, no partial output - the rows sit in the buffer and the program exits. If a table-printing function ever goes silently mute, look for the missing `Flush` before anything else.

## list, grown up

`list` gets its own flag set - the same pattern as `add`, which is the payoff of `NewFlagSet`: each subcommand's flags live in their own world.

```go
// filterByTag returns only the notes carrying the given tag.
func filterByTag(notes []Note, tag string) []Note {
	tag = strings.ToLower(tag)
	var hits []Note
	for _, n := range notes {
		for _, t := range n.Tags {
			if t == tag {
				hits = append(hits, n)
				break
			}
		}
	}
	return hits
}

func runList(args []string) error {
	fs := flag.NewFlagSet("list", flag.ExitOnError)
	n := fs.Int("n", 0, "show only the last n notes (0 = all)")
	tag := fs.String("tag", "", "only notes with this tag")
	fs.Parse(args)

	path, err := storePath()
	if err != nil {
		return err
	}
	notes, err := loadNotes(path)
	if err != nil {
		return err
	}

	if *tag != "" {
		notes = filterByTag(notes, *tag)
	}
	if *n > 0 && len(notes) > *n {
		notes = notes[len(notes)-*n:]
	}
	printTable(notes)
	return nil
}
```

Order matters here, and it's a decision, not an accident: **filter first, then limit.** `til list -tag go -n 3` means "my last three *Go* notes" - so we narrow to the tag before taking the tail. Do it the other way around and a tag that hasn't appeared recently returns nothing, which is never what you meant. `notes[len(notes)-*n:]` takes the last `n` elements - the most recent, since `add` always appends.

(`runList` now takes `args` - remember to pass `os.Args[2:]` in the `main` switch. The full listing below has it.)

## Your turn: searchNotes

Before wiring up the `search` command, try the matching logic yourself. `searchNotes` takes every note plus a query string and returns only the notes whose text contains that query - case-insensitively, matching the function's own comment below.

```go
// searchNotes returns notes whose text contains the query, case-insensitively.
func searchNotes(notes []Note, query string) []Note {
	// your turn
	return nil
}
```

`strings.ToLower` (you already reached for it in `parseTags`) and `strings.Contains` are the only two calls you need. My version is in the section below; once `runSearch` is wired in, `go run . search lifo` should match notes containing "LIFO" despite the case difference - exactly what the walkthrough shows later in this phase.

## search

Search is a filter with a lowercase trick. Lowercasing **both** the query and the text before comparing is the standard way to get case-insensitive matching - `strings.Contains` itself is strictly case-sensitive, and a search tool that misses "Defer" when you type "defer" feels broken even when it's technically working.

```go
// searchNotes returns notes whose text contains the query, case-insensitively.
func searchNotes(notes []Note, query string) []Note {
	q := strings.ToLower(query)
	var hits []Note
	for _, n := range notes {
		if strings.Contains(strings.ToLower(n.Text), q) {
			hits = append(hits, n)
		}
	}
	return hits
}

func runSearch(args []string) error {
	if len(args) == 0 {
		return errors.New(`usage: til search "query"`)
	}
	query := strings.Join(args, " ")

	path, err := storePath()
	if err != nil {
		return err
	}
	notes, err := loadNotes(path)
	if err != nil {
		return err
	}

	hits := searchNotes(notes, query)
	if len(hits) == 0 {
		fmt.Printf("No notes matching %q.\n", query)
		return nil
	}
	printTable(hits)
	return nil
}
```

This is a linear scan of every note - and for this tool, that's the right call. Even thousands of notes scan in well under a blink; anything cleverer would be solving a problem you don't have.

Note the "no results" path prints a sentence and returns `nil`: finding nothing is a valid answer, not an error. Exit codes should mean "the tool failed," not "the world disappointed you."

## tags

The tag summary is a counting problem, and counting in Go means a map:

```go
func runTags() error {
	path, err := storePath()
	if err != nil {
		return err
	}
	notes, err := loadNotes(path)
	if err != nil {
		return err
	}

	counts := map[string]int{}
	for _, n := range notes {
		for _, t := range n.Tags {
			counts[t]++
		}
	}
	if len(counts) == 0 {
		fmt.Println("No tags yet.")
		return nil
	}

	names := make([]string, 0, len(counts))
	for name := range counts {
		names = append(names, name)
	}
	sort.Strings(names)

	w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
	fmt.Fprintln(w, "TAG\tNOTES")
	for _, name := range names {
		fmt.Fprintf(w, "%s\t%d\n", name, counts[name])
	}
	return w.Flush()
}
```

⚠️ **Gotcha:** the detour through a sorted `names` slice isn't decoration. **Go maps iterate in deliberately randomized order** - print the map directly and the tags come out shuffled differently on every run. The runtime does this on purpose, so nobody accidentally depends on an ordering the language never promised. Any time map contents face a human, collect the keys and sort them.

## The full program

Here's `main.go` complete, so you and I are looking at the same file. New since phase 3: `printTable`, `filterByTag`, the rewritten `runList`, `searchNotes`/`runSearch`, `runTags`, two new imports (`sort`, `text/tabwriter`), and the expanded `usage` and `main` switch. The Note type, `parseTags`, and all three storage functions are unchanged.

```go
package main

import (
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"text/tabwriter"
	"time"
)

type Note struct {
	ID      int       `json:"id"`
	Text    string    `json:"text"`
	Tags    []string  `json:"tags,omitempty"`
	Created time.Time `json:"created"`
}

// parseTags turns "Go, CLI" into ["go", "cli"].
func parseTags(s string) []string {
	if s == "" {
		return nil
	}
	var tags []string
	for _, p := range strings.Split(s, ",") {
		p = strings.TrimSpace(strings.ToLower(p))
		if p != "" {
			tags = append(tags, p)
		}
	}
	return tags
}

// storePath returns the notes file location: ~/.til/notes.json.
func storePath() (string, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return "", fmt.Errorf("finding home directory: %w", err)
	}
	return filepath.Join(home, ".til", "notes.json"), nil
}

// loadNotes reads the store. A missing file is a first run, not an error.
func loadNotes(path string) ([]Note, error) {
	data, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		return []Note{}, nil
	}
	if err != nil {
		return nil, err
	}
	var notes []Note
	if err := json.Unmarshal(data, &notes); err != nil {
		return nil, fmt.Errorf("parsing %s: %w", path, err)
	}
	return notes, nil
}

// saveNotes writes the store atomically: temp file first, then rename.
func saveNotes(path string, notes []Note) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	data, err := json.MarshalIndent(notes, "", "  ")
	if err != nil {
		return err
	}
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, data, 0o644); err != nil {
		return err
	}
	return os.Rename(tmp, path)
}

// searchNotes returns notes whose text contains the query, case-insensitively.
func searchNotes(notes []Note, query string) []Note {
	q := strings.ToLower(query)
	var hits []Note
	for _, n := range notes {
		if strings.Contains(strings.ToLower(n.Text), q) {
			hits = append(hits, n)
		}
	}
	return hits
}

// filterByTag returns only the notes carrying the given tag.
func filterByTag(notes []Note, tag string) []Note {
	tag = strings.ToLower(tag)
	var hits []Note
	for _, n := range notes {
		for _, t := range n.Tags {
			if t == tag {
				hits = append(hits, n)
				break
			}
		}
	}
	return hits
}

// printTable renders notes as an aligned table.
func printTable(notes []Note) {
	if len(notes) == 0 {
		fmt.Println(`No notes yet. Add one: til add "what you learned"`)
		return
	}
	w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
	fmt.Fprintln(w, "ID\tDATE\tTAGS\tNOTE")
	for _, n := range notes {
		fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", n.ID, n.Created.Format("2006-01-02"), strings.Join(n.Tags, ","), n.Text)
	}
	w.Flush()
}

func runAdd(args []string) error {
	fs := flag.NewFlagSet("add", flag.ExitOnError)
	tags := fs.String("tags", "", "comma-separated tags, e.g. -tags go,cli")
	fs.Parse(args)

	text := strings.TrimSpace(strings.Join(fs.Args(), " "))
	if text == "" {
		return errors.New(`nothing to add - usage: til add [-tags a,b] "your note"`)
	}

	path, err := storePath()
	if err != nil {
		return err
	}
	notes, err := loadNotes(path)
	if err != nil {
		return err
	}

	id := 1
	if len(notes) > 0 {
		id = notes[len(notes)-1].ID + 1
	}
	notes = append(notes, Note{ID: id, Text: text, Tags: parseTags(*tags), Created: time.Now()})

	if err := saveNotes(path, notes); err != nil {
		return err
	}
	fmt.Printf("Added note #%d\n", id)
	return nil
}

func runList(args []string) error {
	fs := flag.NewFlagSet("list", flag.ExitOnError)
	n := fs.Int("n", 0, "show only the last n notes (0 = all)")
	tag := fs.String("tag", "", "only notes with this tag")
	fs.Parse(args)

	path, err := storePath()
	if err != nil {
		return err
	}
	notes, err := loadNotes(path)
	if err != nil {
		return err
	}

	if *tag != "" {
		notes = filterByTag(notes, *tag)
	}
	if *n > 0 && len(notes) > *n {
		notes = notes[len(notes)-*n:]
	}
	printTable(notes)
	return nil
}

func runSearch(args []string) error {
	if len(args) == 0 {
		return errors.New(`usage: til search "query"`)
	}
	query := strings.Join(args, " ")

	path, err := storePath()
	if err != nil {
		return err
	}
	notes, err := loadNotes(path)
	if err != nil {
		return err
	}

	hits := searchNotes(notes, query)
	if len(hits) == 0 {
		fmt.Printf("No notes matching %q.\n", query)
		return nil
	}
	printTable(hits)
	return nil
}

func runTags() error {
	path, err := storePath()
	if err != nil {
		return err
	}
	notes, err := loadNotes(path)
	if err != nil {
		return err
	}

	counts := map[string]int{}
	for _, n := range notes {
		for _, t := range n.Tags {
			counts[t]++
		}
	}
	if len(counts) == 0 {
		fmt.Println("No tags yet.")
		return nil
	}

	names := make([]string, 0, len(counts))
	for name := range counts {
		names = append(names, name)
	}
	sort.Strings(names)

	w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
	fmt.Fprintln(w, "TAG\tNOTES")
	for _, name := range names {
		fmt.Fprintf(w, "%s\t%d\n", name, counts[name])
	}
	return w.Flush()
}

func usage() {
	fmt.Println(`til - a tiny "today I learned" log

Usage:
  til add [-tags a,b] "your note"
  til list [-n 5] [-tag go]
  til search "query"
  til tags`)
}

func main() {
	if len(os.Args) < 2 {
		usage()
		os.Exit(1)
	}

	var err error
	switch os.Args[1] {
	case "add":
		err = runAdd(os.Args[2:])
	case "list":
		err = runList(os.Args[2:])
	case "search":
		err = runSearch(os.Args[2:])
	case "tags":
		err = runTags()
	default:
		fmt.Fprintf(os.Stderr, "unknown command %q\n", os.Args[1])
		usage()
		os.Exit(1)
	}
	if err != nil {
		fmt.Fprintln(os.Stderr, "til:", err)
		os.Exit(1)
	}
}
```

## Run it all

Add a third note so the filters have something to disagree about, then put every command through its paces:

```console
$ go run . add -tags git "git stash pops in LIFO order too"
Added note #3
$ go run . list
ID  DATE        TAGS    NOTE
1   2026-07-06  go      defer runs in LIFO order
2   2026-07-06  go,cli  flag.NewFlagSet gives each subcommand its own flags
3   2026-07-06  git     git stash pops in LIFO order too
```

*What just happened:* tabwriter measured every column - the widest tag cell is `go,cli`, so the whole TAGS column sized itself to that - and padded the rest to match. No column widths appear anywhere in our code.

```console
$ go run . search lifo
ID  DATE        TAGS  NOTE
1   2026-07-06  go    defer runs in LIFO order
3   2026-07-06  git   git stash pops in LIFO order too
$ go run . list -tag go -n 1
ID  DATE        TAGS    NOTE
2   2026-07-06  go,cli  flag.NewFlagSet gives each subcommand its own flags
$ go run . tags
TAG  NOTES
cli  1
git  1
go   2
```

*What just happened:* `search lifo` matched "LIFO" in two notes despite the case difference. `list -tag go -n 1` narrowed to the two Go-tagged notes, then kept the most recent one. And `tags` counted every tag across all notes and printed them alphabetically - run it ten times, same order ten times, because we sorted.

Finish with a fresh binary, since this is now a tool worth keeping built:

```console
$ go build
$ ./til list -n 2
ID  DATE        TAGS    NOTE
2   2026-07-06  go,cli  flag.NewFlagSet gives each subcommand its own flags
3   2026-07-06  git     git stash pops in LIFO order too
```

## What you have now

The complete feature set: capture, browse, filter, search, and summarize - with output that looks like a tool someone maintains. What you *don't* have is proof it keeps working. The logic is now spread across enough functions that a careless edit could quietly break search or corrupt the round-trip to disk. Next phase we pin it down with real tests - and you'll see why every function in this file that makes a decision takes plain data in and returns plain data out.

Three checks on the ideas that carry this phase:

```quiz
[
  {
    "q": "How does text/tabwriter get the columns to line up?",
    "choices": [
      "You configure fixed column widths when creating the writer",
      "It uses your terminal's built-in tab stops directly",
      "It buffers all rows, measures each column's widest cell, and pads with spaces on Flush"
    ],
    "answer": 2,
    "explain": "That buffering is also the gotcha: skip Flush and the buffered rows are simply discarded - the table prints nothing."
  },
  {
    "q": "You run: til list -tag go -n 3. What does the code do?",
    "choices": [
      "Takes the last 3 notes overall, then keeps the go-tagged ones",
      "Selects the go-tagged notes first, then shows the last 3 of those",
      "Prints an error because -tag and -n cannot combine"
    ],
    "answer": 1,
    "explain": "Filter first, then limit - so the command means 'my last three Go notes'. The other order would often return nothing."
  },
  {
    "q": "Why does runTags copy the map keys into a slice and sort them before printing?",
    "choices": [
      "Go maps iterate in deliberately randomized order, so unsorted output would shuffle on every run",
      "tabwriter requires its rows in alphabetical order",
      "Sorting makes the map lookups faster"
    ],
    "answer": 0,
    "explain": "The runtime randomizes map iteration on purpose so programs can't depend on an unpromised order. Sort the keys whenever map contents face a human."
  }
]
```


---

# Tests That Catch Real Bugs

Right now, `til` works because you watched it work. That guarantee expires the next time you touch the code - add an `edit` command in three months, nudge `searchNotes` while you're in there, and nothing will tell you that search stopped being case-insensitive. Tests are how the guarantee outlives your attention span. This phase writes real ones: not ceremony, not 100% coverage - a handful of tests aimed at the behavior that would hurt to lose.

And here's the payoff I promised twice: because the logic functions take plain data and return plain data - `searchNotes(notes, query)`, not "read the file, search it, print results" - testing them requires **no setup at all.** Build a slice, call the function, check the result. The design choice was the hard part, and it's already done.

## How Go testing works

No framework to install. The `testing` package and `go test` command ship with the toolchain, and the rules fit in three lines:

- Tests live in files ending in `_test.go`, which `go build` ignores - test code never bloats your shipped binary.
- Each test is a function named `TestXxx` taking `*testing.T`.
- A test fails if it calls `t.Errorf` or `t.Fatalf`; otherwise it passes. That's the entire API you need today.

Because our test file declares `package main` - the same package as `main.go` - it can call `searchNotes`, `loadNotes`, and the rest directly, no exporting required.

## The test file

Create `main_test.go` next to `main.go`:

```go
package main

import (
	"path/filepath"
	"testing"
	"time"
)

func sample() []Note {
	return []Note{
		{ID: 1, Text: "Go interfaces are satisfied implicitly", Tags: []string{"go"}, Created: time.Now()},
		{ID: 2, Text: "git rebase rewrites history", Tags: []string{"git"}, Created: time.Now()},
		{ID: 3, Text: "go build makes one binary", Tags: []string{"go", "cli"}, Created: time.Now()},
	}
}

func TestSearchNotes(t *testing.T) {
	tests := []struct {
		name  string
		query string
		want  int
	}{
		{"case insensitive", "GO", 2},
		{"no match", "docker", 0},
		{"multi-word phrase", "one binary", 1},
		{"substring inside a word", "hist", 1},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := searchNotes(sample(), tt.query)
			if len(got) != tt.want {
				t.Errorf("searchNotes(%q) returned %d notes, want %d", tt.query, len(got), tt.want)
			}
		})
	}
}

func TestFilterByTag(t *testing.T) {
	if got := filterByTag(sample(), "go"); len(got) != 2 {
		t.Errorf("tag go: got %d notes, want 2", len(got))
	}
	if got := filterByTag(sample(), "GO"); len(got) != 2 {
		t.Errorf("tag GO should match like go: got %d notes, want 2", len(got))
	}
	if got := filterByTag(sample(), "python"); len(got) != 0 {
		t.Errorf("tag python: got %d notes, want 0", len(got))
	}
}

func TestSaveLoadRoundTrip(t *testing.T) {
	path := filepath.Join(t.TempDir(), "notes.json")

	if err := saveNotes(path, sample()); err != nil {
		t.Fatalf("saveNotes: %v", err)
	}
	got, err := loadNotes(path)
	if err != nil {
		t.Fatalf("loadNotes: %v", err)
	}
	if len(got) != 3 {
		t.Fatalf("got %d notes back, want 3", len(got))
	}
	if got[2].Text != "go build makes one binary" {
		t.Errorf("note text corrupted in round trip: %q", got[2].Text)
	}
	if len(got[2].Tags) != 2 {
		t.Errorf("tags lost in round trip: %v", got[2].Tags)
	}
}

func TestLoadMissingFile(t *testing.T) {
	notes, err := loadNotes(filepath.Join(t.TempDir(), "does-not-exist.json"))
	if err != nil {
		t.Fatalf("a missing file is a first run, not an error - got: %v", err)
	}
	if len(notes) != 0 {
		t.Errorf("want an empty list from a missing file, got %d notes", len(notes))
	}
}
```

Four tests, four different lessons. Let's take them in turn.

**`TestSearchNotes` is table-driven** - the pattern you'll see in nearly every serious Go codebase. Instead of one test function per case, you declare a slice of cases (an anonymous struct with a name, inputs, and the expected result) and loop over it. `t.Run(tt.name, ...)` makes each row a named subtest that passes or fails independently. The win is the marginal cost of a new case: when you wonder "what about a query with an apostrophe?", the answer is one more line in the slice, not a new function. Notice the cases test *behavior you decided on* in phase 4: case-insensitivity, the empty result, multi-word queries, substring matching.

**`TestFilterByTag` guards a promise** - that tag matching ignores case, because `parseTags` lowercases on the way in and `filterByTag` lowercases the query. That promise currently holds because of two lines in two different functions. If anyone "cleans up" either one, this test is the tripwire.

**`TestSaveLoadRoundTrip` tests the disk code against a real disk** - but not *your* disk. `t.TempDir()` hands the test a freshly created directory and registers automatic cleanup when the test ends; nothing to delete, nothing left behind, and - because `saveNotes` takes a path instead of calling `storePath` itself - **the test can't touch your real `~/.til`.** This is the phase-3 design decision paying rent. The test then asserts the property that actually matters: what comes back out equals what went in.

**`TestLoadMissingFile` pins down an edge case** - the "first run is not an error" behavior from phase 3. Edge-case decisions like that one are precisely what future-you forgets; write them down as tests and they stop being folklore.

📝 **Terminology:** `t.Errorf` records a failure and lets the test keep running; `t.Fatalf` records a failure and stops the test immediately. Use `Fatalf` when continuing makes no sense - if `loadNotes` returned an error, inspecting the notes it didn't return would only produce noise.

## Run them

```console
$ go test
PASS
ok      til     0.184s
```

*What just happened:* `go test` found every `_test.go` file in the package, compiled it together with the code under test, ran all four test functions, and summarized. Quiet output is the Go way - passing tests have nothing interesting to say. Add `-v` when you want the play-by-play:

```console
$ go test -v
=== RUN   TestSearchNotes
=== RUN   TestSearchNotes/case_insensitive
=== RUN   TestSearchNotes/no_match
=== RUN   TestSearchNotes/multi-word_phrase
=== RUN   TestSearchNotes/substring_inside_a_word
--- PASS: TestSearchNotes (0.00s)
    --- PASS: TestSearchNotes/case_insensitive (0.00s)
    --- PASS: TestSearchNotes/no_match (0.00s)
    --- PASS: TestSearchNotes/multi-word_phrase (0.00s)
    --- PASS: TestSearchNotes/substring_inside_a_word (0.00s)
=== RUN   TestFilterByTag
--- PASS: TestFilterByTag (0.00s)
=== RUN   TestSaveLoadRoundTrip
--- PASS: TestSaveLoadRoundTrip (0.00s)
=== RUN   TestLoadMissingFile
--- PASS: TestLoadMissingFile (0.00s)
PASS
ok      til     0.201s
```

Each table row shows up as its own named subtest - that's `t.Run` earning its keep.

## Watch a test actually catch something

A passing suite proves nothing until you've seen it fail. So break the code the way a hurried future-you might: in `main.go`, edit `searchNotes` and remove the `strings.ToLower` around `n.Text` - the kind of change that survives a glance at a diff, because the function still compiles and still finds *lowercase* matches.

```console
$ go test
--- FAIL: TestSearchNotes (0.00s)
    --- FAIL: TestSearchNotes/case_insensitive (0.00s)
        main_test.go:32: searchNotes("GO") returned 0 notes, want 2
FAIL
exit status 1
FAIL    til     0.198s
```

*What just happened:* one subtest failed - exactly the one guarding the behavior you broke - and its message says what was expected and what happened instead. The other three search cases still pass, because lowercase queries still work; that precision is the point of subtests. Manual testing would likely have missed this: you'd type a lowercase query, see results, and ship.

Put the `strings.ToLower` back and confirm you're green:

```console
$ go test
PASS
ok      til     0.190s
```

That loop - break, see red, fix, see green - is the entire testing workflow. From now on, `go test` before every commit costs two seconds and buys you the confidence to change anything.

## What you have now

A tool *and* a safety net: the search and filter behavior, the storage round trip, and the first-run edge case are all pinned down by tests that run in a fraction of a second. Notice what we didn't test - `main`'s dispatch, `usage` printing, tabwriter's alignment. Thin wiring over the standard library, visible every time you run the tool, is not where the bugs that hurt live. Test judgment beats test coverage.

One thing left: `til` still only exists in this folder, on this machine. Next phase we compile it for every OS you care about and put it on your PATH, where a real tool belongs.

Quick check on the testing ideas:

```quiz
[
  {
    "q": "Why does main_test.go declare package main, the same package as the code it tests?",
    "choices": [
      "So the tests can call unexported functions like searchNotes directly",
      "Because go test only works on package main",
      "To make the compiled binary include the tests"
    ],
    "answer": 0,
    "explain": "Same package means full access to unexported names. (go build still excludes _test.go files, so the shipped binary contains no test code.)"
  },
  {
    "q": "What does t.TempDir() give the storage tests?",
    "choices": [
      "A faster in-memory filesystem",
      "A fresh directory per test, deleted automatically afterward - so tests never touch your real ~/.til",
      "A directory shared by all tests so they can pass files to each other"
    ],
    "answer": 1,
    "explain": "Combined with saveNotes and loadNotes taking a path parameter, tests exercise the real file code against a disposable location."
  },
  {
    "q": "In TestSaveLoadRoundTrip, why t.Fatalf for the loadNotes error but t.Errorf for the text check?",
    "choices": [
      "Fatalf stops the test - if loading failed there are no notes worth inspecting; Errorf records the problem and continues",
      "Fatalf is required for errors returned by functions",
      "They behave identically; the choice is style"
    ],
    "answer": 0,
    "explain": "Fatalf for 'cannot meaningfully continue', Errorf for 'note this failure and keep checking'. Using Errorf after a failed load would produce a cascade of noise."
  }
]
```


---

# Cross-Compile and Ship It

Your tool works, and it's tested. Two things separate it from the CLIs you use every day: it only exists for one operating system, and you can only run it from one folder. This phase fixes both - and the first fix is going to feel like a trick when you see it, because on most platforms, building for a *different* platform is a miserable afternoon of toolchains and SDKs. In Go it's two environment variables.

## The two knobs: GOOS and GOARCH

A compiled binary is machine code for a specific operating system and a specific CPU family. Remember phase 1, when `go version` printed `windows/amd64` (or your equivalent)? That pair - OS and architecture - is what `go build` targets, and by default it targets the machine you're on. You can see the current values any time:

```console
$ go env GOOS GOARCH
windows
amd64
```

Set them to something else and `go build` produces a binary for *that* platform instead. The whole standard library is written in Go and compiles for every target, so there's no cross-compiler to install, no SDK to download - the toolchain you already have contains everything. (The catch: this holds as long as your project is pure Go, like ours. Depend on a package that wraps C code - some SQLite drivers, for instance - and cross-compiling stops being free. It's a real reason people keep CLI tools stdlib-only.)

The pairs you'll actually use:

| GOOS | GOARCH | Runs on |
|------|--------|---------|
| `windows` | `amd64` | Windows PCs |
| `darwin` | `arm64` | Apple silicon Macs (M1 and later) |
| `darwin` | `amd64` | Intel Macs |
| `linux` | `amd64` | Most Linux servers and desktops |
| `linux` | `arm64` | Raspberry Pi, ARM servers, AWS Graviton |

## Build for three platforms from one chair

On macOS or Linux, you can set the variables for a single command by prefixing it:

```console
$ GOOS=linux GOARCH=amd64 go build -o til-linux
$ GOOS=darwin GOARCH=arm64 go build -o til-mac
$ GOOS=windows GOARCH=amd64 go build -o til.exe
$ ls
go.mod  main.go  main_test.go  til-linux  til-mac  til.exe
```

PowerShell has no prefix syntax - set the variables, build, then unset them so later builds target your own machine again:

```powershell
$env:GOOS = "linux"; $env:GOARCH = "amd64"
go build -o til-linux

$env:GOOS = "darwin"; $env:GOARCH = "arm64"
go build -o til-mac

Remove-Item Env:GOOS, Env:GOARCH
```

*What just happened:* three binaries, three platforms, one machine, a few seconds each. The `-o` flag names the output file - essential here, since all three would otherwise want the same default name. Windows executables need the `.exe` suffix; the other platforms don't use one.

⚠️ **Gotcha:** a cross-compiled binary runs on its *target*, not on you. Build `til-linux` on Windows and try to run it locally and Windows will refuse ("this app can't run on your PC"); a Linux binary on a Mac dies with `exec format error`. The file isn't broken - it's a perfectly healthy Linux program standing in the wrong country. Copy it to a Linux machine, `chmod +x til-linux`, and it runs.

⚠️ **Gotcha, part two:** if you set `$env:GOOS` in PowerShell and forget to remove it, *every* future `go build` and `go run` in that terminal quietly targets Linux - including next week's, if your terminal restores sessions. If Go ever starts producing binaries your own machine can't run, check `go env GOOS` first.

That's distribution solved at the file level: hand `til-mac` to a Mac-owning teammate and it runs, no Go, no installer, no dependencies. This single ability is a large part of why Go owns the CLI-tool niche.

## Put it on your PATH

Typing `./til` from one specific folder is fine for a project; a *tool* answers from anywhere. When you type a bare command name, your shell walks the directories listed in the `PATH` environment variable looking for a matching executable. So `til` needs to live in one of those directories - and Go has a one-command way to do it:

```console
$ go install
```

*What just happened:* silence, which for Go means success. `go install` compiled the module and dropped the binary into Go's designated bin directory: `go env GOPATH` tells you the root (typically `~/go` on macOS/Linux, `C:\Users\you\go` on Windows), and the binaries go in its `bin` subfolder.

```console
$ go env GOPATH
C:\Users\you\go
```

One thing may remain, one time only: making sure that bin folder is on your PATH.

- **Windows:** Start menu → "Edit environment variables for your account" → select `Path` → Edit → New → add `%USERPROFILE%\go\bin`. Open a fresh terminal afterward.
- **macOS / Linux:** add `export PATH="$HOME/go/bin:$PATH"` to your shell's startup file (`~/.zshrc` on modern macOS, `~/.bashrc` on most Linux), then open a fresh terminal.

If you've installed any Go tool before, this is already done. Now the moment the whole guide was building toward - a new terminal, any directory:

```console
$ cd ~
$ til add -tags meta "til is on my PATH now"
Added note #4
$ til tags
TAG   NOTES
cli   1
git   1
go    2
meta  1
```

*What just happened:* no `./`, no project folder, no `go run`. The shell found `til` on your PATH like it finds `git`. Your notes are all still there, because the store lives in `~/.til`, not in the project - a phase-3 decision that quietly becomes load-bearing the moment the binary escapes its folder. `til` is now installed software that you wrote. When you change the code, `go install` again - it recompiles and replaces the binary in place.

## Where to take it next

The tool is genuinely done - and genuinely useful, which makes it the perfect codebase to keep learning in. The real next steps, roughly in the order they start to itch:

| Want | What it takes |
|------|--------------|
| **A delete command** | `til delete 3`: parse the ID with `strconv.Atoi`, keep every note whose ID doesn't match, save. Stable IDs (phase 3) mean nothing renumbers. Write the test first - you have the pattern. |
| **An edit command** | Same shape as delete: find by ID, replace the text, save. |
| **Export to Markdown** | A `til export` that walks the notes and prints a Markdown list, ready for a blog or a team wiki. Pure formatting - no new concepts. |
| **A version flag** | Add `var version = "dev"` in main.go, print it on `til -version`, and stamp releases at build time with `go build -ldflags "-X main.version=1.0.0"`. |
| **A friendlier CLI framework** | When subcommands multiply and you want auto-generated help and shell completion, the community standard is `spf13/cobra` (it powers kubectl and gh). You'll understand exactly what it automates, because you built the manual version. |
| **Real releases** | Tag versions in git and publish binaries for every platform on a GitHub release page. The `goreleaser` tool automates the build matrix you did by hand above. |

Notice none of these require rearchitecting. Subcommand dispatch, path-taking storage functions, logic separated from printing - the structure you built is the structure these features slot into.

## What you built

Start to finish: a Go module compiled to a single self-contained binary; a subcommand dispatcher with per-command flag sets; JSON persistence that treats a missing file as day one and survives a crash mid-write; search, filtering, and aligned table output; a test suite that caught a planted bug; and binaries for three operating systems, one of which now answers from anywhere on your machine.

More durable than the tool: you now know the shape of every CLI you'll ever build. Parse arguments, dispatch to a handler, keep logic in pure functions, persist state carefully, print for humans, test the decisions. `til` is that shape at its smallest useful size - and everything from `git` to `kubectl` is the same shape, bigger.

Last quiz of the project:

```quiz
[
  {
    "q": "What do GOOS and GOARCH control?",
    "choices": [
      "The compiler's optimization level and debug output",
      "The target operating system and CPU architecture of the binary go build produces",
      "Which directory go install puts the binary in"
    ],
    "answer": 1,
    "explain": "Set the pair and go build emits machine code for that platform - no extra toolchain needed, as long as the project is pure Go."
  },
  {
    "q": "On your Windows machine you run GOOS=linux go build. Can you run the resulting binary locally?",
    "choices": [
      "Yes - Go binaries run on any operating system",
      "No - it is a Linux executable now; your machine needs the windows/amd64 build",
      "Only through go run"
    ],
    "answer": 1,
    "explain": "A binary is machine code for its target platform. Cross-compiling changes where the output runs, not where it was built."
  },
  {
    "q": "Where does go install put the compiled binary?",
    "choices": [
      "In the current project directory",
      "In /usr/local/bin or C:\\Program Files",
      "In the bin folder under go env GOPATH - typically ~/go/bin"
    ],
    "answer": 2,
    "explain": "That folder is Go's home for installed tools. Put it on your PATH once and every future go install lands ready to run."
  }
]
```
