Содержание урока · 11 разделов
- Каркас репозитория: cmd/, internal/tasks и main.go
- Хранилище: JSON в домашнем каталоге
- Команды add, done, rm и коды выхода
- list: флаги --status и --all, цветной вывод
- Версия и Ctrl+C: два маленьких блока в main
- Тесты: Execute в памяти, хранилище во временном каталоге
- Чеклист сборки проекта
- Поздравление и куда дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Каталог проекта пуст, в редакторе открыт main.go с парой строк, в терминале мигает приглашение — и на всё про всё двадцать четыре минуты. Это нормальная точка старта итогового проекта: новых знаний он не требует, он требует умения складывать старые в один репозиторий так, чтобы через месяц не было стыдно. Утилита tasks: задачи добавляются командой add, список показывается list с фильтрами, done закрывает задачу по номеру, rm удаляет. Данные лежат в одном JSON-файле в домашнем каталоге, вывод раскрашен, ошибки возвращают честные коды выхода, а вшитая версия отвечает на --version. Готовое cli приложение go пример такого сорта собирается за вечер и живёт в системе годами; весь код ниже вы уже видели по частям — теперь он встретится целиком, и я буду звать предыдущие уроки по именам, а не переписывать их.
Каркас репозитория: cmd/, internal/tasks и main.go#
Раскладка из урока про структуру проекта переносится без изменений, и это лучшая проверка того, что она была не теорией. Модуль называется github.com/you/tasks; внутри main.go на тридцать строк, каталог cmd с одним файлом на команду — root.go, add.go, list.go, done.go, rm.go — плюс app.go с приёмником зависимостей, errors.go с ExitError и colors.go со стилями. Домен живёт в internal/tasks: модель Task, хранилище Store, сентинел ErrNotFound — и ни одного импорта cobra, домен не знает про терминал. Конфигурация — в internal/config, единственном пакете, где живёт viper. Связка — структура App: фабрики команд принимают её аргументом и замыкают на себя, команды тянут из неё Store и Config и не лезут в глобальные переменные. Если ваша раскладка после двадцати четырёх уроков получилась другой — это не провал: раскладка обслуживает проект, а не наоборот. Обоснование каждой полки — в уроке про структуру проекта.
main.go: тридцать строк, держащих весь проект
GOpackage main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/you/tasks/cmd"
"github.com/you/tasks/internal/config"
"github.com/you/tasks/internal/tasks"
)
var (
version = "dev"
commit = "unknown"
)
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt, syscall.SIGTERM)
defer stop()
app := cmd.NewApp(tasks.NewStore(cfg.TasksFile), cfg)
root := cmd.NewRootCmd(app)
root.Version = version
if err := root.ExecuteContext(ctx); err != nil {
var ee *cmd.ExitError
if errors.As(err, &ee) {
os.Exit(ee.Code)
}
os.Exit(1)
}
}Точка входа держит три контракта курса. Конфиг: config.Load собирает viper-источники и возвращает структуру с путём хранилища, ошибка даёт код 2 — это проблема использования, а не сбой. Сигналы: NotifyContext из урока про graceful shutdown отменяет контекст по Ctrl+C, ExecuteContext доставляет его каждой RunE. Коды выхода: errors.As разбирает ExitError и отдаёт словарю право голоса, общий провал — единица. Поле Version заполнит линковщик, commit ждёт релиза.
Хранилище: JSON в домашнем каталоге#
Путь к данным решается одной функцией stdlib: os.UserConfigDir() возвращает ~/.config на Linux, ~/Library/Application Support на macOS и %AppData% на Windows, и хранилище ложится в подкаталог tasks рядом с config.yaml. Пуристы XDG отправят данные в ~/.local/share через os.UserHomeDir — по букве стандарта они правы, отделять данные от настроек положено; я сознательно беру UserConfigDir, потому что одна функция stdlib вместо ручной склейки путей для утилиты на триста строк — честная сделка, а не лень. Контракт Store прост: Load читает файл и молчит, если его нет, — первый запуск не ошибка, а пустой список; Save маршалит срез с отступами, создаёт каталог и пишет с правами 0o600, потому что в задачах бывают личные вещи. Про формат — спорное мнение: пока задач меньше нескольких сотен, JSON с MarshalIndent читается глазами и правится руками, и я не тяну SQLite в утилиту, которая обязана собираться за секунду; порог, когда плоский файл перестаёт поспевать, — тысячи записей и частичные обновления на каждый чих, тогда и переедем.
internal/tasks/store.go: Load, Save, Done
GOvar ErrNotFound = errors.New("задача не найдена")
type Store struct {
path string
items []Task
next int
}
func NewStore(path string) *Store { return &Store{path: path, next: 1} }
func (s *Store) Load() error {
data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return nil // первый запуск: пусто — это не ошибка
}
if err != nil {
return fmt.Errorf("читаю %s: %w", s.path, err)
}
return json.Unmarshal(data, &s.items)
}
func (s *Store) Save() error {
data, err := json.MarshalIndent(s.items, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return err
}
return os.WriteFile(s.path, data, 0o600)
}
func (s *Store) Done(id int) error {
for i := range s.items {
if s.items[i].ID == id {
s.items[i].Status = StatusDone
return s.Save()
}
}
return fmt.Errorf("done %d: %w", id, ErrNotFound)
}Load прощает только отсутствие файла: errors.Is различает «пусто» и «сломано», битый JSON вернёт ошибку с путём через %w. Save создаёт каталог с 0o700 и файл с 0o600 — разница в том, кто может листать каталог и кто читать данные. Done меняет статус по номеру и оборачивает промах в ErrNotFound; этот сентинел вы увидите снова в тестах, где errors.Is отличит его от сбоя диска.
Проверка: первый запуск
Первый запуск tasks на чистой машине: файла tasks.json ещё нет. Что вернёт Store.Load()?
Команды add, done, rm и коды выхода#
Команда add берёт ровно один аргумент — текст задачи — через Args: cobra.ExactArgs(1) и печатает строку вида добавлено: 3, чтобы номер был виден сразу же. Done и rm ищут запись по номеру; промах оборачивается fmt.Errorf с %w вокруг ErrNotFound, и main переводит это в код 1 — общий провал. Словарь кодов из урока про ошибки живёт в документации, а не в голове: 0 — успех, 1 — провал, 2 — ошибка использования вроде неизвестного статуса в --status, 3 — частичный успех, пригодится будущей команде sync. Rm опаснее остальных: в терминале он спрашивает подтверждение через диалог из урока про интерактивный ввод, в скриптах требует флаг --yes, а на отказ возвращает сентинел errDeclined — тест из урока про тестирование ловит его через errors.Is, не читая текст. На корне включён SilenceUsage: опечатка во флаге не должна вываливать страницу команд вслед за сообщением об ошибке — пользователь уже знает, где --help.
list: флаги --status и --all, цветной вывод#
Семантика list — первое проектное решение, которое стоит проговорить вслух. По умолчанию команда показывает только открытые задачи: как git status молчит про чистые файлы, так и list не печатает закрытое — за статусом глаза ходят к тому, что осталось сделать. Флаг --status done покажет выполненное, --status todo повторит умолчание явно, --all снимает фильтр целиком. Значение --status валидируется до обращения к хранилищу: что-то кроме todo и done возвращает ExitError с кодом 2 — ошибка использования ловится на входе, а не после чтения файла. Вывод идёт через text/tabwriter: три колонки — номер, отметка, текст — выравниваются по самой широкой ячейке, и таблица не расползается на длинных заголовках. Цвет берётся из cmd/colors.go, где fatih/color объявляет okStyle и todoStyle один раз: галочка зелёная, точка серая, и это единственное место проекта, знающее про ANSI-байты. Библиотека сама проверяет isatty и NO_COLOR, а глобальный флаг --color со значениями auto, always и never из урока про цветной вывод управляет color.NoColor, когда автоматика угадала не то.
cmd/list.go: валидация, фильтр, таблица
GOfunc newListCmd(app *App) *cobra.Command {
var status string
var all bool
cmd := &cobra.Command{
Use: "list",
Short: "Показать задачи",
RunE: func(cmd *cobra.Command, args []string) error {
if status != "" && status != string(tasks.StatusTodo) &&
status != string(tasks.StatusDone) {
return &ExitError{Code: 2,
Err: fmt.Errorf("--status: неизвестный статус %q", status)}
}
w := tabwriter.NewWriter(cmd.OutOrStdout(), 2, 4, 2, ' ', 0)
for _, t := range app.Store.List(status, all) {
mark := okStyle.Sprint("✓")
if t.Status != tasks.StatusDone {
mark = todoStyle.Sprint("·")
}
fmt.Fprintf(w, "%d\t%s\t%s\n", t.ID, mark, t.Text)
}
return w.Flush()
},
}
cmd.Flags().StringVar(&status, "status", "", "фильтр: todo или done")
cmd.Flags().BoolVar(&all, "all", false, "показать всё, включая выполненное")
return cmd
}Валидация статуса идёт первой строкой RunE и не стоит ни одного вызова хранилища. Фильтрация живёт в Store.List(status, all), а не в команде: домен умеет отбирать, cmd умеет показывать — та же граница слоёв, что в уроке про структуру. Стили Sprint возвращают строку с кодами или без: решение о цвете приняла библиотека при старте. Flush обязателен — без него tabwriter удержит выровненные строки в буфере, и list напечатает пустоту.
Проверка: цвет в пайпе
Вывод уходит в пайп: tasks list | grep кофе. Что получит grep?
Что окажется в tasks.json
Прочитайте код и выберите, что он выведет:
// internal/tasks/model.go
type Task struct {
ID int `json:"id"`
Text string `json:"text"`
Status Status `json:"status"`
}
data, _ := json.MarshalIndent([]Task{{ID: 1, Text: "звонок", Status: StatusDone}}, "", " ")
fmt.Println(string(data))Версия и Ctrl+C: два маленьких блока в main#
Версия в проекте занимает три строки исходника и одну команду сборки. В main объявлены var version = "dev" и commit, поле Version корня заполняется при старте — Cobra сама добавляет команду version и флаг --version с выводом tasks version dev. Релизная сборка подменяет значения линковщиком: go build -ldflags "-X 'main.version=v1.4.2' -X 'main.commit=9f3ac12'" — и утилита начинает отвечать честно. Отдельную команду version писать не нужно: всё уже сделала Cobra, шаблон вывода при желании меняется через SetVersionTemplate. Сигналы — вторая половина блока: NotifyContext превращает Ctrl+C в отмену контекста, ExecuteContext доставляет её каждой RunE, а defer stop() возвращает терминалу контроль после второго нажатия. Для менеджера задач это страховка про запас: add и list отрабатывают за миллисекунды, ловить в них нечего. Но привычка переносится без переделок: когда в задачах появится sync с сервером — она же даст коду 3 частичного успеха работу, — контекст уже будет протянут куда надо, и graceful shutdown сведётся к проверке ctx.Done() в цикле закачки.
Тесты: Execute в памяти, хранилище во временном каталоге#
Каркас из урока про тестирование доезжает до финала почти без изменений, с одной поправкой на DI: runCmd принимает App, потому что корень создаётся фабрикой NewRootCmd(app) на каждый вызов — персистентные флаги pflag не переползают между тестами. Хранилище в тестах указывает на t.TempDir(): каталог тестовый раннер удалит сам, и ни один тест не касается реальных задач пользователя. Сценарий пробегает полный жизненный цикл — add, list, done, list --status done — и на каждом шаге проверяет буфер через strings.Contains. Валидация --status ложится в табличный тест: пустой статус, todo, мусорное значение — три кейса с ожидаемыми wantErr и wantOut. Длинный вывод таблицы закрывают golden-файлы из cmd/testdata: эталон обновляется флагом -update осознанно, дифф читается в ревью как правка интерфейса. Прогон go test ./... требует живого Go с модулями проекта — песочница курса тестовый раннер и файловую систему не эмулирует, поэтому сборку и тесты вы делаете локально, в своём терминале.
cmd/root_test.go: сценарий на весь жизненный цикл
GOfunc newTestApp(t *testing.T) *App {
t.Helper()
path := filepath.Join(t.TempDir(), "tasks.json")
return NewApp(tasks.NewStore(path), config.Config{})
}
func TestAdd_Done_Filter(t *testing.T) {
app := newTestApp(t)
if _, err := runCmd(app, "add", "купить кофе"); err != nil {
t.Fatalf("add: %v", err)
}
out, err := runCmd(app, "list")
if err != nil || !strings.Contains(out, "кофе") {
t.Fatalf("list: err=%v out=%q", err, out)
}
if _, err := runCmd(app, "done", "1"); err != nil {
t.Fatalf("done: %v", err)
}
out, _ = runCmd(app, "list", "--status", "done")
if !strings.Contains(out, "кофе") {
t.Errorf("выполненная не видна в --status done:\n%s", out)
}
}newTestApp прячет рутину: t.TempDir, путь tasks.json внутри, конфиг-заглушка. Тест идёт по сценарию пользователя, а не по функциям пакета: только Execute проходит тот же путь, что и терминал, с парсингом флагов и валидацией. Проверки через Contains устойчивы к правкам форматирования — таблица меняется, смысл нет. Один такой тест заменяет прогон руками после каждой правки.
Ранняя версия этого проекта ловила баг на себе: хелпер newApp() читал конфиг по умолчанию, брал настоящий путь хранилища, и табличный тест done 1, done 2, done 3 за секунду закрыл двести четырнадцать реальных задач — с осмысленными текстами за три года. Резервной копии не было, JSON истории не хранит. Лечение заняло час, привычка — навсегда: App в тестах строится только с путём во временный каталог, настоящий конфиг читает исключительно main, а правило ревью звучит жёстко — тест, в котором не видно, откуда взялся путь хранилища, считается опасным, пока не доказано обратное. Файловые утилиты прощают всё, кроме одного прогона по живым данным.
Соберите Save по строкам
Впишите маршал с отступами, создание каталога и права файла.
Соберите config.Load по шагам
Расставьте тело config.Load от создания viper до возврата структуры.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Чеклист сборки проекта#
- go mod init github.com/you/tasks && go mod tidy — в go.sum ложатся cobra, viper и fatih/color
- gofmt -l . и go vet ./... — оба молчат
- go test ./... — зелёный: табличные кейсы, golden-файлы, сценарий add–done–list
- go build -o tasks . — ./tasks --version печатает tasks version dev
- go build -ldflags "-X 'main.version=v0.1.0'" — версия вшита, ответ меняется
- ./tasks add "первая задача" — каталог ~/.config/tasks создан, файл с правами 0o600
- ./tasks list | cat — вывод без ANSI-кодов: пайп остался чистым
- ./tasks done 99; echo $? — единица: ErrNotFound дошёл до кода выхода
- ./tasks completion zsh — скрипт автодополнения генерируется, как в уроке про completion
Карточки: детали проекта
Поздравление и куда дальше#
Если чеклист зелёный — на диске лежит законченная утилита, а не учебный пример: с конфигом, цветами, кодами выхода, версией и тестами. Курс Cobra пройден целиком, и это видно по репозиторию — поздравляю. Идеи доработок лежат на поверхности: дедлайны у задач с подсветкой просроченных, поиск search по подстроке, экспорт в Markdown, синхронизация между машинами через git — она же оживит код 3 частичного успеха, а когда записей станет много, хранилище переедет в SQLite. Два направления уже ждут в соседних курсах. Первый — TUI-режим: тот же list, но интерактивной таблицей с курсором, поиском и статус-баром — это курс Bubble Tea, и ваш домен из internal/tasks переедет туда без единой правки, потому что не знает про терминал. Второй — структурные логи для sync и отладки: курс Zap покажет, как заменить fmt.Fprintln на уровни и поля. Обзор всех уроков — на странице Cobra. Соберите бинарник локально, положите в PATH — и пусть tasks живёт в вашем терминале.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Репозиторий tasks собирает весь курс: cmd/ с командами, internal/tasks с доменом, main.go на тридцать строк.
- Хранилище — JSON в ~/.config/tasks/tasks.json: Load прощает отсутствие файла, Save создаёт каталог и пишет с правами 0o600.
- Коды выхода — контракт для скриптов: 0 успех, 1 провал, 2 ошибка использования, 3 частичный успех; путь через ExitError и errors.As.
- Цвет через fatih/color: стили объявлены один раз, в пайпе отключаются сами; --status и --all фильтруют list.
- Версия приходит ldflags-ом в var version; сигналы — NotifyContext и ExecuteContext из урока про graceful shutdown.
- Тесты гоняют Execute в памяти: runCmd с App, t.TempDir для хранилища, табличные сценарии и golden-файлы.
- Дальше — TUI на Bubble Tea и структурные логи Zap: CLI остаётся ядром, интерфейсы наращиваются слоями.
Домашнее задание
Довершите утилиту командой search: tasks search TEXT фильтрует задачи по подстроке в тексте, уважает --status и --all, печатает через tabwriter и возвращает код 1 при пустом результате. Добавьте табличный тест на три кейса search во временном каталоге и соберите релизную версию с -ldflags — проверьте, что --version отвечает номером.
Личные заметкиПусто
Вопросы и ответы по уроку
Где tasks хранит задачи и как перенести их на другую машину?
В JSON-файле по пути os.UserConfigDir(): ~/.config/tasks/tasks.json на Linux, ~/Library/Application Support/tasks на macOS, %AppData% на Windows. Перенос — копирование одного файла: это и преимущество плоского хранилища, и его потолок. Путь переопределяется ключом store.file в конфиге или переменной TASKS_STORE_FILE — лестница приоритетов из урока про конфигурацию работает до конца.
Почему list по умолчанию скрывает выполненные задачи?
Это проектное решение: команда, которую вызывают чаще всего, должна показывать остаток работы — как git status показывает изменения, а не всю историю. Выполненное доступно через --status done, полный список — через --all. Если ваша привычка другая, умолчание меняется в одном месте, Store.List, и тест на жизненный цикл это отследит.
Как поставить tasks в систему одной командой?
go install github.com/you/tasks@latest положит бинарник в GOBIN — обычно ~/go/bin; убедитесь, что каталог в PATH. Релизную версию собирайте с -ldflags и тегом, а выпуск на несколько платформ автоматизирует goreleaser из урока про сборку: архивы, контрольные суммы и формула Homebrew делаются одним тегом.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Bubble Tea: TUI-приложения в терминале
Интерактивный курс Bubble Tea — TUI-фреймворка на Go: Elm-архитектура, bubbles, Lip Gloss, мышь и клавиатура. 25 уроков с примерами и итоговым приложением.
- Понимать Elm-архитектуру: Model, Update, View, Init
- Обрабатывать клавиатуру и мышь в терминале
- Писать асинхронные операции через tea.Cmd, Batch и Sequence
- Использовать bubbles: list, textinput, textarea, table, viewport, progress, spinner