Финальный проект: 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 так любят в мире серверной разработки. Это будет краткое, но впечатляющее введение в тему, которая раскроется в полном курсе.