$ sudo teach IT
МОДУЛЬ 5 · УРОК 5.4

Финальный проект: TODO-менеджер в терминале

Собираем все знания воедино и создаём полноценную CLI-программу с хранением данных, фильтрацией и красивым выводом

Вот и наступил момент, ради которого мы прошли весь этот путь. Пять модулей — от «что такое переменная» до пакетов, интерфейсов, работы с файлами и JSON. Сегодня вы соберёте всё вместе в один настоящий проект — TODO-менеджер, который работает прямо в терминале. Вы сможете добавлять задачи, отмечать их выполненными, удалять, фильтровать по статусу — и всё это будет сохраняться в JSON-файл на диске. Код будет разбит по пакетам, как в настоящих Go-проектах. Когда закончите — этот проект можно смело выложить на GitHub и показать работодателю или друзьям.

🎯 Что мы строим

Наша программа будет работать как утилита командной строки. Пользователь запускает её с определёнными командами и флагами, и она выполняет нужное действие. Вот как это будет выглядеть:

# Добавить задачу
./todo add "Купить продукты"

# Показать все задачи
./todo list

# Показать только невыполненные
./todo list --status=pending

# Отметить задачу выполненной
./todo done 1

# Удалить задачу
./todo remove 3

Программа будет хранить задачи в файле todos.json рядом с исполняемым файлом. Каждый раз при запуске она читает этот файл, выполняет нужное действие и сохраняет обновлённые данные обратно.

Какие навыки мы применим:

  • Структуры и методы (модуль 3-4) — модель задачи
  • Срезы и map (модуль 3) — хранение списка задач
  • Работа с файлами (урок 4.3) — чтение и запись на диск
  • JSON (урок 5.2) — сериализация и десериализация
  • Пакеты (урок 4.1) — разбивка по модулям
  • Обработка ошибок (урок 4.2) — корректная работа с ошибками
  • Аргументы командной строки (урок 5.1) — os.Args и flag
  • Функции (урок 2.6) — логика программы

📁 Структура проекта

Прежде чем писать код, давайте спроектируем структуру. В уроке 5.1 мы изучали, как правильно организовывать файлы и папки в Go-проектах. Сейчас применим эти знания.

todo-cli/
├── go.mod
├── cmd/
│   └── todo/
│       └── main.go          # Точка входа: разбор команд
├── internal/
│   ├── task/
│   │   └── task.go          # Модель задачи (структура, методы)
│   ├── storage/
│   │   └── storage.go       # Чтение/запись JSON-файла
│   └── display/
│       └── display.go       # Красивый вывод в терминал
└── todos.json               # Файл с данными (создаётся автоматически)

Давайте разберём, что делает каждая папка:

cmd/todo/main.go — точка входа в программу. Здесь функция main(), которая разбирает аргументы командной строки и вызывает нужные функции. Никакой бизнес-логики — только маршрутизация команд.

internal/task/task.go — описание модели задачи. Структура Task, список задач TaskList и все операции с ними: добавление, удаление, отметка о выполнении, фильтрация.

internal/storage/storage.go — работа с файлом. Загрузка задач из JSON и сохранение обратно. Этот пакет отвечает только за ввод-вывод, он ничего не знает о бизнес-логике.

internal/display/display.go — красивый вывод в терминал. Форматирование таблицы задач, цветной текст, символы статуса.

💡 Почему internal? Папка internal — это специальное соглашение в Go. Пакеты внутри internal не могут быть импортированы из-за пределов вашего модуля. Это защита от случайного использования внутренних деталей реализации другими проектами. В нашем случае — это просто хорошая практика.

Давайте создадим проект. Откройте терминал и выполните:

mkdir -p todo-cli/cmd/todo
mkdir -p todo-cli/internal/task
mkdir -p todo-cli/internal/storage
mkdir -p todo-cli/internal/display
cd todo-cli
go mod init todo-cli

Команда go mod init todo-cli создаст файл go.mod — он описывает наш модуль. После этого Go сможет находить наши пакеты по путям вроде todo-cli/internal/task.

📝 Шаг 1. Модель задачи — internal/task/task.go

Начнём с самого главного — структуры задачи. Каждая задача имеет уникальный номер (ID), текст, статус (выполнена или нет) и дату создания. Вспомните урок 3.4 о структурах — здесь мы применяем те же принципы, только в реальном проекте.

package task

import (
    "errors"
    "time"
)

// Task описывает одну задачу.
// Теги json:"..." указывают, как поля будут называться в JSON-файле.
type Task struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Done      bool      `json:"done"`
    CreatedAt time.Time `json:"created_at"`
}

// TaskList — это срез задач с методами для управления ими.
type TaskList struct {
    Tasks []Task `json:"tasks"`
}

// NewTaskList создаёт пустой список задач.
func NewTaskList() *TaskList {
    return &TaskList{
        Tasks: []Task{},
    }
}

// nextID вычисляет следующий свободный ID.
// Ищем максимальный существующий ID и прибавляем 1.
func (tl *TaskList) nextID() int {
    maxID := 0
    for _, t := range tl.Tasks {
        if t.ID > maxID {
            maxID = t.ID
        }
    }
    return maxID + 1
}

// Add добавляет новую задачу в список.
func (tl *TaskList) Add(title string) Task {
    task := Task{
        ID:        tl.nextID(),
        Title:     title,
        Done:      false,
        CreatedAt: time.Now(),
    }
    tl.Tasks = append(tl.Tasks, task)
    return task
}

// Done отмечает задачу выполненной по её ID.
// Возвращает ошибку, если задача не найдена.
func (tl *TaskList) Done(id int) error {
    for i := range tl.Tasks {
        if tl.Tasks[i].ID == id {
            if tl.Tasks[i].Done {
                return errors.New("задача уже выполнена")
            }
            tl.Tasks[i].Done = true
            return nil
        }
    }
    return errors.New("задача с таким ID не найдена")
}

// Remove удаляет задачу по её ID.
// Возвращает ошибку, если задача не найдена.
func (tl *TaskList) Remove(id int) error {
    for i, t := range tl.Tasks {
        if t.ID == id {
            // Удаляем элемент из среза:
            // берём всё до него + всё после него
            tl.Tasks = append(tl.Tasks[:i], tl.Tasks[i+1:]...)
            return nil
        }
    }
    return errors.New("задача с таким ID не найдена")
}

// Filter возвращает задачи, отфильтрованные по статусу.
// status: "all" — все, "pending" — невыполненные, "done" — выполненные.
func (tl *TaskList) Filter(status string) []Task {
    if status == "all" {
        return tl.Tasks
    }

    var result []Task
    for _, t := range tl.Tasks {
        switch status {
        case "done":
            if t.Done {
                result = append(result, t)
            }
        case "pending":
            if !t.Done {
                result = append(result, t)
            }
        }
    }
    return result
}

// Count возвращает количество всех, выполненных и невыполненных задач.
func (tl *TaskList) Count() (total, done, pending int) {
    total = len(tl.Tasks)
    for _, t := range tl.Tasks {
        if t.Done {
            done++
        }
    }
    pending = total - done
    return total, done, pending
}

Давайте разберём ключевые моменты этого файла по частям.

Структура Task. Мы используем JSON-теги, чтобы при сохранении в файл поля назывались в нижнем регистре через подчёркивание (created_at), а не так, как в Go (CreatedAt). Это стандартная практика — мы её изучали в уроке 5.2.

TaskList — обёртка над срезом. Мы не используем просто []Task, а оборачиваем его в структуру. Зачем? Во-первых, это позволяет добавлять методы. Во-вторых, при сериализации в JSON массив будет обёрнут в объект с ключом "tasks" — это удобнее для чтения и расширения.

Pointer receiver *TaskList. Все методы, которые изменяют список (Add, Done, Remove), используют указатель. Это важно — без указателя изменения не сохранятся в оригинальном объекте. Метод Filter не изменяет список, но тоже использует указатель для единообразия.

Удаление из среза. Строка tl.Tasks = append(tl.Tasks[:i], tl.Tasks[i+1:]...) — классический приём в Go. Мы берём все элементы до удаляемого и все элементы после него, и склеиваем их в новый срез.

Множественный возврат в Count. Функция возвращает три значения одновременно — именованные возвращаемые значения, которые мы изучали в уроке 2.6.

💡 Обратите внимание: Все функции, начинающиеся с заглавной буквы (Add, Done, Remove, Filter), являются публичными — они доступны из других пакетов. Функция nextID со строчной буквы — приватная, она используется только внутри пакета task. Это правило экспорта из урока 4.1.

💾 Шаг 2. Хранение данных — internal/storage/storage.go

Теперь напишем пакет, который отвечает за сохранение задач в файл и загрузку из него. Всё, что мы изучали в уроках 4.3 (файлы) и 5.2 (JSON), пригодится прямо сейчас.

package storage

import (
    "encoding/json"
    "errors"
    "os"

    "todo-cli/internal/task"
)

// Storage управляет чтением и записью задач в JSON-файл.
type Storage struct {
    FilePath string
}

// NewStorage создаёт экземпляр Storage с указанным путём к файлу.
func NewStorage(filePath string) *Storage {
    return &Storage{FilePath: filePath}
}

// Load читает задачи из JSON-файла.
// Если файл не существует, возвращает пустой список.
func (s *Storage) Load() (*task.TaskList, error) {
    // Проверяем, существует ли файл
    _, err := os.Stat(s.FilePath)
    if errors.Is(err, os.ErrNotExist) {
        // Файла нет — возвращаем пустой список
        return task.NewTaskList(), nil
    }
    if err != nil {
        return nil, err
    }

    // Читаем содержимое файла
    data, err := os.ReadFile(s.FilePath)
    if err != nil {
        return nil, err
    }

    // Если файл пустой, возвращаем пустой список
    if len(data) == 0 {
        return task.NewTaskList(), nil
    }

    // Декодируем JSON в структуру TaskList
    var tl task.TaskList
    err = json.Unmarshal(data, &tl)
    if err != nil {
        return nil, err
    }

    return &tl, nil
}

// Save записывает задачи в JSON-файл.
// Использует отступы для читаемости (MarshalIndent).
func (s *Storage) Save(tl *task.TaskList) error {
    // Кодируем в JSON с красивыми отступами
    data, err := json.MarshalIndent(tl, "", "  ")
    if err != nil {
        return err
    }

    // Записываем в файл с правами 0644 (чтение/запись для владельца)
    return os.WriteFile(s.FilePath, data, 0644)
}

Разберём важные моменты этого файла.

Проверка существования файла. При первом запуске программы файла todos.json ещё нет. Функция os.Stat возвращает ошибку, если файл не существует. Мы проверяем это с помощью errors.Is(err, os.ErrNotExist) — приём из урока 4.2 про обработку ошибок. Если файла нет — просто возвращаем пустой список.

json.MarshalIndent. Вместо обычного json.Marshal мы используем MarshalIndent. Разница в том, что он добавляет отступы и переносы строк. Файл todos.json становится читаемым для человека:

{
  "tasks": [
    {
      "id": 1,
      "title": "Купить продукты",
      "done": false,
      "created_at": "2026-04-06T14:30:00Z"
    },
    {
      "id": 2,
      "title": "Написать отчёт",
      "done": true,
      "created_at": "2026-04-06T15:00:00Z"
    }
  ]
}

Права доступа 0644. Число 0644 — это права файла в Unix. Владелец может читать и писать, остальные — только читать. Это стандартные права для обычных файлов.

Импорт внутреннего пакета. Обратите внимание на строку "todo-cli/internal/task". Мы импортируем наш собственный пакет по полному пути модуля. Это возможно, потому что мы запустили go mod init todo-cli — Go знает, что todo-cli является корнем нашего модуля.

💡 Разделение ответственности: Пакет storage не знает, как создавать задачи или что с ними делать. Он только читает и записывает файл. Пакет task не знает, где хранятся данные — он просто управляет списком в памяти. Каждый пакет делает одну вещь и делает её хорошо. Это называется принципом единственной ответственности.

🎨 Шаг 3. Красивый вывод — internal/display/display.go

Программа должна не просто работать, но и хорошо выглядеть. Сделаем красивый вывод в терминал с символами статуса, цветами и выравниванием. Для цветного вывода в терминале используются специальные управляющие последовательности — ANSI escape-коды. Не пугайтесь — это просто специальные символы, которые терминал интерпретирует как команды форматирования.

package display

import (
    "fmt"
    "strings"

    "todo-cli/internal/task"
)

// ANSI-коды цветов для терминала.
// Это специальные последовательности символов, которые терминал
// превращает в цветной текст.
const (
    colorReset  = "\033[0m"
    colorRed    = "\033[31m"
    colorGreen  = "\033[32m"
    colorYellow = "\033[33m"
    colorCyan   = "\033[36m"
    colorGray   = "\033[90m"
    colorBold   = "\033[1m"
)

// PrintTasks выводит список задач в виде красивой таблицы.
func PrintTasks(tasks []task.Task) {
    if len(tasks) == 0 {
        fmt.Println()
        fmt.Printf("  %s📋 Список задач пуст%s\n", colorYellow, colorReset)
        fmt.Println()
        return
    }

    fmt.Println()
    fmt.Printf("  %s%s%-4s %-6s %-40s %s%s\n",
        colorBold, colorCyan,
        "ID", "Статус", "Задача", "Дата",
        colorReset,
    )
    fmt.Printf("  %s%s%s\n", colorGray, strings.Repeat("─", 62), colorReset)

    for _, t := range tasks {
        status := fmt.Sprintf("%s✗%s", colorRed, colorReset)
        if t.Done {
            status = fmt.Sprintf("%s✓%s", colorGreen, colorReset)
        }

        // Обрезаем длинные названия
        title := t.Title
        if len(title) > 38 {
            title = title[:35] + "..."
        }

        date := t.CreatedAt.Format("02.01.2006")

        fmt.Printf("  %-4d   %s   %-40s %s%s%s\n",
            t.ID,
            status,
            title,
            colorGray, date, colorReset,
        )
    }
    fmt.Println()
}

// PrintStats выводит статистику по задачам.
func PrintStats(total, done, pending int) {
    fmt.Printf("  %sВсего: %d%s  |  ", colorBold, total, colorReset)
    fmt.Printf("%s✓ Выполнено: %d%s  |  ", colorGreen, done, colorReset)
    fmt.Printf("%s✗ Осталось: %d%s\n\n", colorYellow, pending, colorReset)
}

// PrintSuccess выводит сообщение об успехе.
func PrintSuccess(message string) {
    fmt.Printf("\n  %s✓ %s%s\n\n", colorGreen, message, colorReset)
}

// PrintError выводит сообщение об ошибке.
func PrintError(message string) {
    fmt.Printf("\n  %s✗ Ошибка: %s%s\n\n", colorRed, message, colorReset)
}

// PrintUsage выводит справку по использованию программы.
func PrintUsage() {
    fmt.Println()
    fmt.Printf("  %s%sTODO Manager — управление задачами из терминала%s\n\n",
        colorBold, colorCyan, colorReset)

    fmt.Printf("  %sИспользование:%s\n", colorBold, colorReset)
    fmt.Printf("    todo %sadd%s \"текст задачи\"     — добавить задачу\n",
        colorGreen, colorReset)
    fmt.Printf("    todo %slist%s                   — показать все задачи\n",
        colorGreen, colorReset)
    fmt.Printf("    todo %slist%s --status=pending  — только невыполненные\n",
        colorGreen, colorReset)
    fmt.Printf("    todo %slist%s --status=done     — только выполненные\n",
        colorGreen, colorReset)
    fmt.Printf("    todo %sdone%s ID                — отметить выполненной\n",
        colorGreen, colorReset)
    fmt.Printf("    todo %sremove%s ID              — удалить задачу\n",
        colorGreen, colorReset)
    fmt.Printf("    todo %sstats%s                  — статистика\n",
        colorGreen, colorReset)
    fmt.Printf("    todo %shelp%s                   — эта справка\n",
        colorGreen, colorReset)
    fmt.Println()
}

Разберём новые концепции из этого файла.

ANSI escape-коды. Последовательность \033[32m говорит терминалу: «начни печатать зелёным цветом». После этого весь текст будет зелёным, пока мы не сбросим цвет с помощью \033[0m. Это работает во всех современных терминалах — macOS, Linux, и даже в Windows Terminal.

strings.Repeat. Функция strings.Repeat("─", 62) создаёт строку из 62 символов ─ — это горизонтальная линия-разделитель. Мы изучали пакет strings в уроке 3.3.

Форматирование ширины. В fmt.Printf формат %-40s означает: выведи строку с шириной 40 символов и выровняй влево (знак минус). Это создаёт ровные столбцы в таблице.

Формат даты. В Go дата форматируется по «эталонной дате» — 02.01.2006. Это не произвольное число! Go использует конкретные цифры как шаблон: 01 — месяц, 02 — день, 2006 — год. Странно, но так устроен Go.

🚀 Шаг 4. Точка входа — cmd/todo/main.go

Теперь соберём всё вместе. Файл main.go — это «мозг» программы. Он принимает аргументы от пользователя, вызывает нужные функции из наших пакетов и управляет потоком выполнения.

package main

import (
    "fmt"
    "os"
    "strconv"
    "strings"

    "todo-cli/internal/display"
    "todo-cli/internal/storage"
)

// dataFile — путь к файлу с задачами.
const dataFile = "todos.json"

func main() {
    // Если аргументов нет — показываем справку
    if len(os.Args) < 2 {
        display.PrintUsage()
        return
    }

    // Создаём хранилище и загружаем задачи
    store := storage.NewStorage(dataFile)
    tasks, err := store.Load()
    if err != nil {
        display.PrintError("не удалось загрузить задачи: " + err.Error())
        os.Exit(1)
    }

    // Первый аргумент — это команда
    command := os.Args[1]

    switch command {
    case "add":
        handleAdd(tasks, store)
    case "list":
        handleList(tasks)
    case "done":
        handleDone(tasks, store)
    case "remove":
        handleRemove(tasks, store)
    case "stats":
        handleStats(tasks)
    case "help":
        display.PrintUsage()
    default:
        display.PrintError(fmt.Sprintf("неизвестная команда: %s", command))
        display.PrintUsage()
    }
}

// handleAdd обрабатывает команду добавления задачи.
func handleAdd(tasks *task.TaskList, store *storage.Storage) {
    if len(os.Args) < 3 {
        display.PrintError("укажите текст задачи: todo add \"Купить молоко\"")
        return
    }

    // Собираем все аргументы после "add" в одну строку
    title := strings.Join(os.Args[2:], " ")

    newTask := tasks.Add(title)

    err := store.Save(tasks)
    if err != nil {
        display.PrintError("не удалось сохранить: " + err.Error())
        return
    }

    display.PrintSuccess(fmt.Sprintf(
        "Задача #%d добавлена: %s", newTask.ID, newTask.Title,
    ))
}

// handleList обрабатывает команду показа задач.
func handleList(tasks *task.TaskList) {
    // Проверяем, есть ли флаг --status=
    status := "all"
    for _, arg := range os.Args[2:] {
        if strings.HasPrefix(arg, "--status=") {
            status = strings.TrimPrefix(arg, "--status=")
        }
    }

    filtered := tasks.Filter(status)
    display.PrintTasks(filtered)

    // Показываем статистику под списком
    total, done, pending := tasks.Count()
    display.PrintStats(total, done, pending)
}

// handleDone обрабатывает команду выполнения задачи.
func handleDone(tasks *task.TaskList, store *storage.Storage) {
    if len(os.Args) < 3 {
        display.PrintError("укажите ID задачи: todo done 1")
        return
    }

    id, err := strconv.Atoi(os.Args[2])
    if err != nil {
        display.PrintError("ID должен быть числом")
        return
    }

    err = tasks.Done(id)
    if err != nil {
        display.PrintError(err.Error())
        return
    }

    err = store.Save(tasks)
    if err != nil {
        display.PrintError("не удалось сохранить: " + err.Error())
        return
    }

    display.PrintSuccess(fmt.Sprintf("Задача #%d отмечена как выполненная", id))
}

// handleRemove обрабатывает команду удаления задачи.
func handleRemove(tasks *task.TaskList, store *storage.Storage) {
    if len(os.Args) < 3 {
        display.PrintError("укажите ID задачи: todo remove 1")
        return
    }

    id, err := strconv.Atoi(os.Args[2])
    if err != nil {
        display.PrintError("ID должен быть числом")
        return
    }

    err = tasks.Remove(id)
    if err != nil {
        display.PrintError(err.Error())
        return
    }

    err = store.Save(tasks)
    if err != nil {
        display.PrintError("не удалось сохранить: " + err.Error())
        return
    }

    display.PrintSuccess(fmt.Sprintf("Задача #%d удалена", id))
}

// handleStats выводит статистику.
func handleStats(tasks *task.TaskList) {
    total, done, pending := tasks.Count()
    fmt.Println()
    display.PrintStats(total, done, pending)
}

Стоп! Вы заметили, что в этом файле мы забыли импорт. В строке func handleAdd(tasks *task.TaskList, ...) используется пакет task, но его нет в блоке import. Давайте исправим это. Вот правильный блок импортов:

import (
    "fmt"
    "os"
    "strconv"
    "strings"

    "todo-cli/internal/display"
    "todo-cli/internal/storage"
    "todo-cli/internal/task"
)

Теперь разберём логику main.go подробнее.

Разбор аргументов через os.Args. Мы используем os.Args — срез строк, содержащий аргументы командной строки. os.Args[0] — это имя программы, os.Args[1] — первая команда (add, list и т.д.), os.Args[2:] — остальные аргументы.

Switch для маршрутизации. Конструкция switch command направляет выполнение в нужную функцию-обработчик. Каждая функция handleXxx отвечает за одну команду — это делает код чистым и поддерживаемым.

Паттерн: загрузить → изменить → сохранить. Каждая команда, изменяющая данные (add, done, remove), следует одному паттерну: загружаем задачи из файла, выполняем операцию, сохраняем обратно. Команды, которые только читают данные (list, stats), не вызывают store.Save.

Ручной парсинг --status=. Для флага --status= мы используем strings.HasPrefix и strings.TrimPrefix. Это проще, чем подключать пакет flag, потому что flag требует, чтобы все флаги шли до позиционных аргументов. В нашей программе подкоманды (add, list) — это позиционные аргументы, и flag с ними плохо дружит без дополнительной настройки.

💡 strconv.Atoi — это функция из пакета strconv, которая переводит строку в число. Имя расшифровывается как «ASCII to Integer». Она возвращает два значения: число и ошибку. Если строка не является числом (например, "abc"), ошибка не будет nil.

⚙️ Шаг 5. Сборка и запуск

Все файлы написаны. Давайте соберём проект и протестируем его. Перейдите в корневую папку проекта todo-cli и выполните:

# Собираем проект в исполняемый файл
go build -o todo ./cmd/todo

# Теперь у нас есть файл "todo" — наша программа!

Команда go build -o todo ./cmd/todo компилирует пакет cmd/todo и создаёт исполняемый файл с именем todo. Флаг -o задаёт имя выходного файла. Теперь запустим и проверим:

# Показать справку
./todo help

# Добавляем задачи
./todo add "Купить продукты"
./todo add "Написать отчёт по проекту"
./todo add "Позвонить маме"
./todo add "Сделать зарядку"
./todo add "Прочитать главу книги"

# Смотрим список
./todo list

# Отмечаем выполненными
./todo done 1
./todo done 4

# Смотрим только невыполненные
./todo list --status=pending

# Смотрим только выполненные
./todo list --status=done

# Статистика
./todo stats

# Удаляем задачу
./todo remove 2

# Проверяем финальный список
./todo list

Если вы всё сделали правильно, вы увидите красивую таблицу с цветными галочками и крестиками, аккуратно выровненными столбцами и статистикой внизу.

Загляните в файл todos.json — там будут ваши задачи в формате JSON. Этот файл переживёт перезапуск программы, перезагрузку компьютера — данные сохранены на диске.

Отладка. Если что-то не работает, проверьте: 1) Вы находитесь в корневой папке todo-cli. 2) Все файлы лежат по правильным путям. 3) Имя модуля в go.mod совпадает с импортами (todo-cli). 4) Все экспортируемые имена начинаются с большой буквы.

🔗 Чему мы научились за весь курс

Давайте оглянемся назад и посмотрим, сколько всего мы использовали в этом проекте. Каждый элемент — это урок из нашего курса.

Модуль 2 — Основы языка. Переменные, условия (if/switch), циклы (for range), функции с множественным возвратом, fmt.Printf с форматированием ширины.

Модуль 3 — Структуры данных. Срезы (append, удаление элемента), структуры с методами (pointer receiver), пакет strings (HasPrefix, TrimPrefix, Join, Repeat), strconv.Atoi.

Модуль 4 — Организация кода. Пакеты и модули (import, public/private), обработка ошибок (errors.New, errors.Is), работа с файлами (os.ReadFile, os.WriteFile, os.Stat), JSON-теги в структурах.

Модуль 5 — Проект. Архитектура cmd/internal, os.Args для аргументов, json.Marshal/Unmarshal, полноценный рабочий проект от и до.

Это не учебный проект «для галочки». Это реальная рабочая программа, построенная по стандартам индустрии Go. Структура cmd + internal — это то, как организованы тысячи реальных Go-проектов на GitHub.

📦 Шаг 6. Выкладываем на GitHub

Этот проект достоин того, чтобы оказаться на GitHub. Это ваш первый Go-проект, и он показывает, что вы умеете работать с файлами, JSON, аргументами командной строки и правильно организовывать код. Давайте подготовим его к публикации.

Первое, что нужно — это файл .gitignore, чтобы не отправлять на GitHub ненужные файлы:

# .gitignore

# Исполняемый файл
todo

# Файл с данными (у каждого свой)
todos.json

Второе — файл README.md с описанием проекта. Это визитная карточка на GitHub:

# TODO Manager CLI

Менеджер задач в терминале, написанный на Go.

## Возможности

- Добавление, удаление, просмотр задач
- Отметка задач как выполненных
- Фильтрация по статусу (все / выполненные / невыполненные)
- Хранение данных в JSON-файле
- Цветной вывод в терминале
- Статистика по задачам

## Установка

```bash
git clone https://github.com/ваш-логин/todo-cli.git
cd todo-cli
go build -o todo ./cmd/todo
```

## Использование

```bash
./todo add "Текст задачи"        # добавить задачу
./todo list                      # все задачи
./todo list --status=pending     # невыполненные
./todo list --status=done        # выполненные
./todo done 1                    # отметить выполненной
./todo remove 1                  # удалить
./todo stats                     # статистика
```

## Структура проекта

```
cmd/todo/main.go          - точка входа
internal/task/task.go      - модель задачи
internal/storage/storage.go - хранение в JSON
internal/display/display.go - вывод в терминал
```

Теперь инициализируем Git и отправляем на GitHub:

# Инициализация репозитория
cd todo-cli
git init
git add .
git commit -m "Initial commit: TODO Manager CLI"

# Создайте репозиторий на github.com, затем:
git remote add origin https://github.com/ваш-логин/todo-cli.git
git push -u origin main

💡 Для портфолио: Если вы только начинаете карьеру, такой проект на GitHub говорит работодателю: «Этот человек умеет писать чистый, организованный код на Go. Он понимает работу с файлами, JSON, командной строкой и принципы разделения кода на пакеты.» Это намного ценнее, чем сотня решённых задачек на LeetCode.

💡 Идеи для самостоятельного развития проекта

Наш TODO-менеджер уже полноценный, но его можно развивать дальше. Вот несколько идей, которые помогут вам продолжить обучение:

Приоритеты задач. Добавьте поле Priority (low, medium, high) в структуру Task. Реализуйте сортировку по приоритету и фильтрацию --priority=high.

Дедлайны. Добавьте поле Deadline time.Time. Показывайте просроченные задачи красным цветом. Добавьте команду overdue для просмотра просроченных.

Редактирование задач. Добавьте команду edit 1 "Новый текст" для изменения текста задачи после создания.

Категории. Добавьте поле Category string и возможность фильтрации по категориям: todo list --category=работа.

Поиск. Добавьте команду todo search "молоко", которая ищет задачи по ключевым словам с помощью strings.Contains.

Каждая из этих доработок — отличная возможность потренировать то, что вы выучили. Попробуйте реализовать хотя бы одну — это лучший способ закрепить знания.

📄 Полный код проекта: все файлы

Для удобства вот все файлы проекта в одном месте. Если вы пропустили что-то выше или хотите убедиться, что ничего не потерялось — вот полный готовый проект.

go.mod:

module todo-cli

go 1.22

cmd/todo/main.go:

package main

import (
    "fmt"
    "os"
    "strconv"
    "strings"

    "todo-cli/internal/display"
    "todo-cli/internal/storage"
    "todo-cli/internal/task"
)

const dataFile = "todos.json"

func main() {
    if len(os.Args) < 2 {
        display.PrintUsage()
        return
    }

    store := storage.NewStorage(dataFile)
    tasks, err := store.Load()
    if err != nil {
        display.PrintError("не удалось загрузить задачи: " + err.Error())
        os.Exit(1)
    }

    command := os.Args[1]

    switch command {
    case "add":
        handleAdd(tasks, store)
    case "list":
        handleList(tasks)
    case "done":
        handleDone(tasks, store)
    case "remove":
        handleRemove(tasks, store)
    case "stats":
        handleStats(tasks)
    case "help":
        display.PrintUsage()
    default:
        display.PrintError(fmt.Sprintf("неизвестная команда: %s", command))
        display.PrintUsage()
    }
}

func handleAdd(tasks *task.TaskList, store *storage.Storage) {
    if len(os.Args) < 3 {
        display.PrintError("укажите текст задачи: todo add \"Купить молоко\"")
        return
    }

    title := strings.Join(os.Args[2:], " ")
    newTask := tasks.Add(title)

    if err := store.Save(tasks); err != nil {
        display.PrintError("не удалось сохранить: " + err.Error())
        return
    }

    display.PrintSuccess(fmt.Sprintf("Задача #%d добавлена: %s", newTask.ID, newTask.Title))
}

func handleList(tasks *task.TaskList) {
    status := "all"
    for _, arg := range os.Args[2:] {
        if strings.HasPrefix(arg, "--status=") {
            status = strings.TrimPrefix(arg, "--status=")
        }
    }

    filtered := tasks.Filter(status)
    display.PrintTasks(filtered)

    total, done, pending := tasks.Count()
    display.PrintStats(total, done, pending)
}

func handleDone(tasks *task.TaskList, store *storage.Storage) {
    if len(os.Args) < 3 {
        display.PrintError("укажите ID задачи: todo done 1")
        return
    }

    id, err := strconv.Atoi(os.Args[2])
    if err != nil {
        display.PrintError("ID должен быть числом")
        return
    }

    if err := tasks.Done(id); err != nil {
        display.PrintError(err.Error())
        return
    }

    if err := store.Save(tasks); err != nil {
        display.PrintError("не удалось сохранить: " + err.Error())
        return
    }

    display.PrintSuccess(fmt.Sprintf("Задача #%d отмечена как выполненная", id))
}

func handleRemove(tasks *task.TaskList, store *storage.Storage) {
    if len(os.Args) < 3 {
        display.PrintError("укажите ID задачи: todo remove 1")
        return
    }

    id, err := strconv.Atoi(os.Args[2])
    if err != nil {
        display.PrintError("ID должен быть числом")
        return
    }

    if err := tasks.Remove(id); err != nil {
        display.PrintError(err.Error())
        return
    }

    if err := store.Save(tasks); err != nil {
        display.PrintError("не удалось сохранить: " + err.Error())
        return
    }

    display.PrintSuccess(fmt.Sprintf("Задача #%d удалена", id))
}

func handleStats(tasks *task.TaskList) {
    total, done, pending := tasks.Count()
    fmt.Println()
    display.PrintStats(total, done, pending)
}

internal/task/task.go:

package task

import (
    "errors"
    "time"
)

type Task struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Done      bool      `json:"done"`
    CreatedAt time.Time `json:"created_at"`
}

type TaskList struct {
    Tasks []Task `json:"tasks"`
}

func NewTaskList() *TaskList {
    return &TaskList{
        Tasks: []Task{},
    }
}

func (tl *TaskList) nextID() int {
    maxID := 0
    for _, t := range tl.Tasks {
        if t.ID > maxID {
            maxID = t.ID
        }
    }
    return maxID + 1
}

func (tl *TaskList) Add(title string) Task {
    task := Task{
        ID:        tl.nextID(),
        Title:     title,
        Done:      false,
        CreatedAt: time.Now(),
    }
    tl.Tasks = append(tl.Tasks, task)
    return task
}

func (tl *TaskList) Done(id int) error {
    for i := range tl.Tasks {
        if tl.Tasks[i].ID == id {
            if tl.Tasks[i].Done {
                return errors.New("задача уже выполнена")
            }
            tl.Tasks[i].Done = true
            return nil
        }
    }
    return errors.New("задача с таким ID не найдена")
}

func (tl *TaskList) Remove(id int) error {
    for i, t := range tl.Tasks {
        if t.ID == id {
            tl.Tasks = append(tl.Tasks[:i], tl.Tasks[i+1:]...)
            return nil
        }
    }
    return errors.New("задача с таким ID не найдена")
}

func (tl *TaskList) Filter(status string) []Task {
    if status == "all" {
        return tl.Tasks
    }

    var result []Task
    for _, t := range tl.Tasks {
        switch status {
        case "done":
            if t.Done {
                result = append(result, t)
            }
        case "pending":
            if !t.Done {
                result = append(result, t)
            }
        }
    }
    return result
}

func (tl *TaskList) Count() (total, done, pending int) {
    total = len(tl.Tasks)
    for _, t := range tl.Tasks {
        if t.Done {
            done++
        }
    }
    pending = total - done
    return total, done, pending
}

internal/storage/storage.go:

package storage

import (
    "encoding/json"
    "errors"
    "os"

    "todo-cli/internal/task"
)

type Storage struct {
    FilePath string
}

func NewStorage(filePath string) *Storage {
    return &Storage{FilePath: filePath}
}

func (s *Storage) Load() (*task.TaskList, error) {
    _, err := os.Stat(s.FilePath)
    if errors.Is(err, os.ErrNotExist) {
        return task.NewTaskList(), nil
    }
    if err != nil {
        return nil, err
    }

    data, err := os.ReadFile(s.FilePath)
    if err != nil {
        return nil, err
    }

    if len(data) == 0 {
        return task.NewTaskList(), nil
    }

    var tl task.TaskList
    err = json.Unmarshal(data, &tl)
    if err != nil {
        return nil, err
    }

    return &tl, nil
}

func (s *Storage) Save(tl *task.TaskList) error {
    data, err := json.MarshalIndent(tl, "", "  ")
    if err != nil {
        return err
    }

    return os.WriteFile(s.FilePath, data, 0644)
}

internal/display/display.go:

package display

import (
    "fmt"
    "strings"

    "todo-cli/internal/task"
)

const (
    colorReset  = "\033[0m"
    colorRed    = "\033[31m"
    colorGreen  = "\033[32m"
    colorYellow = "\033[33m"
    colorCyan   = "\033[36m"
    colorGray   = "\033[90m"
    colorBold   = "\033[1m"
)

func PrintTasks(tasks []task.Task) {
    if len(tasks) == 0 {
        fmt.Println()
        fmt.Printf("  %s📋 Список задач пуст%s\n", colorYellow, colorReset)
        fmt.Println()
        return
    }

    fmt.Println()
    fmt.Printf("  %s%s%-4s %-6s %-40s %s%s\n",
        colorBold, colorCyan,
        "ID", "Статус", "Задача", "Дата",
        colorReset,
    )
    fmt.Printf("  %s%s%s\n", colorGray, strings.Repeat("─", 62), colorReset)

    for _, t := range tasks {
        status := fmt.Sprintf("%s✗%s", colorRed, colorReset)
        if t.Done {
            status = fmt.Sprintf("%s✓%s", colorGreen, colorReset)
        }

        title := t.Title
        if len(title) > 38 {
            title = title[:35] + "..."
        }

        date := t.CreatedAt.Format("02.01.2006")

        fmt.Printf("  %-4d   %s   %-40s %s%s%s\n",
            t.ID,
            status,
            title,
            colorGray, date, colorReset,
        )
    }
    fmt.Println()
}

func PrintStats(total, done, pending int) {
    fmt.Printf("  %sВсего: %d%s  |  ", colorBold, total, colorReset)
    fmt.Printf("%s✓ Выполнено: %d%s  |  ", colorGreen, done, colorReset)
    fmt.Printf("%s✗ Осталось: %d%s\n\n", colorYellow, pending, colorReset)
}

func PrintSuccess(message string) {
    fmt.Printf("\n  %s✓ %s%s\n\n", colorGreen, message, colorReset)
}

func PrintError(message string) {
    fmt.Printf("\n  %s✗ Ошибка: %s%s\n\n", colorRed, message, colorReset)
}

func PrintUsage() {
    fmt.Println()
    fmt.Printf("  %s%sTODO Manager — управление задачами из терминала%s\n\n",
        colorBold, colorCyan, colorReset)

    fmt.Printf("  %sИспользование:%s\n", colorBold, colorReset)
    fmt.Printf("    todo %sadd%s \"текст задачи\"     — добавить задачу\n",
        colorGreen, colorReset)
    fmt.Printf("    todo %slist%s                   — показать все задачи\n",
        colorGreen, colorReset)
    fmt.Printf("    todo %slist%s --status=pending  — только невыполненные\n",
        colorGreen, colorReset)
    fmt.Printf("    todo %slist%s --status=done     — только выполненные\n",
        colorGreen, colorReset)
    fmt.Printf("    todo %sdone%s ID                — отметить выполненной\n",
        colorGreen, colorReset)
    fmt.Printf("    todo %sremove%s ID              — удалить задачу\n",
        colorGreen, colorReset)
    fmt.Printf("    todo %sstats%s                  — статистика\n",
        colorGreen, colorReset)
    fmt.Printf("    todo %shelp%s                   — эта справка\n",
        colorGreen, colorReset)
    fmt.Println()
}

📋 Итоги урока и всего курса

  • Мы создали полноценный TODO-менеджер для терминала с 4 файлами в 3 пакетах
  • Данные хранятся в JSON-файле и переживают перезапуск программы
  • Реализованы все основные операции: добавление, удаление, выполнение, фильтрация, статистика
  • Код разбит по принципу единственной ответственности: task — логика, storage — файлы, display — вывод
  • Красивый цветной вывод с ANSI-кодами работает в любом современном терминале
  • Структура проекта (cmd + internal) соответствует стандартам Go-сообщества
  • Проект готов к публикации на GitHub с .gitignore и README.md
  • Мы применили знания из всех пяти модулей курса: переменные, циклы, функции, структуры, срезы, пакеты, ошибки, файлы, JSON

Поздравляю! Вы прошли основной курс по Go. Вы начинали с нуля — и теперь у вас есть рабочий проект, который вы понимаете от первой до последней строки. Это серьёзное достижение. Язык Go открывает двери в бэкенд-разработку, DevOps, облачные технологии и многое другое. Вы сделали первый и самый важный шаг.

Дальше — бонус-модуль! В уроке 6.1 мы заглянем в одну из самых мощных возможностей Go — горутины. Вы узнаете, что такое конкурентность, как запустить тысячу задач одновременно одной строчкой кода, и почему именно за это Go так любят в мире серверной разработки. Это будет краткое, но впечатляющее введение в тему, которая раскроется в полном курсе.

Финальный проект — Язык Go – для начинающих! | $ sudo teach IT