1. Роль фильтров и место в пайплайне вывода
Если CLI выводит список задач «как есть», он очень быстро превращается в бесконечный рулон текста, который приятно читать только любителям страдать. Фильтры решают простую задачу: отвечают на вопрос «что показываем?», не вмешиваясь в вопрос «как показываем?». И это ключевой момент: фильтрация — это не печать fmt.Println по условию, а строгий этап подготовки данных, который работает на структурах ([]Task), а не на строках.
Представьте, что пользователь хочет «показать только выполненные задачи» или «показать задачи, где в заголовке есть слово “налоги”», или «всё, что было создано после определённой даты». Это классические кейсы фильтров. Если вы спроектируете их предсказуемо, ваш CLI будет удобен и людям, и скриптам. Если спроектируете «с магией» — пользователи будут думать, что приложение живёт своей жизнью.
Нарисуем место фильтров в пайплайне вывода (это важно держать в голове, чтобы не смешивать этапы):
flowchart LR A[Получили задачи из хранилища] --> B[Применили фильтры] B --> C[Отсортировали] C --> D[Отрендерили table/json]
2. Флаги состояния --done и --undone
Флаги --done и --undone кажутся простыми, пока вы не зададите два неудобных вопроса.
Первый: что делать, если пользователь ничего не указал? CLI — это про договоры, поэтому ответы должны быть заранее определены и одинаковы во всех запусках. Самый здоровый дефолт: если фильтры не заданы — показываем всё.
Второй: что делать, если пользователь указал оба флага? Комбинация --done --undone выглядит как «покажи выполненные и невыполненные», то есть «покажи всё». Но проблема в том, что пользователь мог нажать оба случайно, а мы получим поведение, похожее на дефолтное, и не заметим ошибку. Для UX это плохо: человек думал, что фильтрует, а фильтрации не произошло. Поэтому чаще выбирают правило: --done и --undone вместе — ошибка ввода (usage error), которую мы ловим до выполнения команды.
Зафиксируем контракт в виде таблицы — это полезно и для нас, и для будущего README:
| Флаги | Что показываем | Почему так |
|---|---|---|
| ничего | все задачи | дефолт предсказуемый |
|
только Done=true | явно попросили |
|
только Done=false | явно попросили |
|
ошибка | конфликт, вероятная опечатка пользователя |
Теперь — минимальная структура для фильтров. Важно: фильтры — это входные параметры этапа фильтрации, а не «логика, размазанная по коду».
package main
type Filters struct {
DoneOnly bool
UndoneOnly bool
Contains string
}
3. Фильтры --contains и --since
--contains: простая подстрока без «магии»
Фильтр --contains обычно звучит как «покажи задачи, в заголовке которых есть подстрока». И тут у нас появляется соблазн «сделать удобно»: игнорировать регистр, резать пробелы, поддержать регулярные выражения, транслитерацию, поиск по словам. Стоп. Мы проектируем CLI для новичков и для стабильного контракта. Значит, фильтр должен быть простым, прозрачным и предсказуемым.
Самый честный вариант: strings.Contains(title, query) и всё. Если хотим «без сюрпризов», то нужно явно определить, что означает пустая строка. Обычно --contains "" не нужен, поэтому договор такой: пустая строка означает «фильтр не задан», и мы ничего не фильтруем по подстроке.
Ещё одно важное решение: чувствительность к регистру. Если сделать регистронезависимо, это вроде бы «удобнее», но вносит скрытую обработку (strings.ToLower) и вопросы про локали (а их мы сегодня не трогаем). Поэтому для базового курса лучше выбрать прямолинейный вариант: поиск чувствителен к регистру. А если захотим расширить — это будет осознанная версия контракта, а не «оно само как-то».
Мини-предикат для contains:
package main
import "strings"
func containsTitle(title, q string) bool {
if q == "" {
return true // фильтр не задан
}
return strings.Contains(title, q)
}
--since: даты, парсинг и понятные ошибки
--since — это фильтр «покажи задачи, созданные не раньше заданной даты». И тут важнее всего не сама дата, а формат и поведение при ошибке. Если вы принимаете дату строкой, вы обязаны выбрать формат, который легко печатать и легко читать. Для CLI почти всегда выигрывает ISO-подобный формат YYYY-MM-DD, потому что он сортируется как текст и выглядит одинаково во всех странах.
В Go парсинг таких дат делается через магический layout "2006-01-02". Это выглядит как пароль от сейфа, но на самом деле это просто «эталонная дата», по которой Go понимает, где год, где месяц, где день.
У --since есть ещё одна типичная проблема: фильтр опциональный, и нам нужно различать «пользователь не задавал дату» и «пользователь задал дату, но она нулевая». Для этого удобно возвращать тройку: (time.Time, bool, error) — значение, признак «задано ли», и ошибка.
package main
import (
"fmt"
"time"
)
func parseSince(s string) (time.Time, bool, error) {
if s == "" {
return time.Time{}, false, nil
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return time.Time{}, false, fmt.Errorf("bad --since %q, expected YYYY-MM-DD", s)
}
return t, true, nil
}
Обратите внимание: мы не продолжаем выполнение «на авось». Неверная дата — это ошибка ввода. В CLI лучше упасть сразу и честно, чем сделать вид, что вы поняли человека.
Чтобы фильтровать по дате, нашей задаче нужен CreatedAt:
package main
import "time"
type Task struct {
ID int
Title string
Done bool
CreatedAt time.Time
}
И проверка:
package main
import "time"
func isAfterSince(createdAt time.Time, since time.Time, sinceSet bool) bool {
if !sinceSet {
return true
}
return !createdAt.Before(since) // createdAt >= since
}
4. Реализация: предикаты, валидация и применение фильтров
Предикат как контракт: func(Task) bool
Когда фильтров становится больше одного, появляется классическая ловушка: «давайте прямо в цикле for напишем 10 условий и в середине где-нибудь распечатаем». Работать будет, но читать это потом будет больно. Гораздо приятнее держать фильтрацию как «функцию, отвечающую на вопрос подходит ли элемент».
Такую функцию часто называют предикатом. В нашем случае предикат выглядит как func(Task) bool. Мы можем написать функцию matches(t, f) и складывать правила в понятном порядке. Хороший стиль — ранние return false, потому что это читается как «если нарушено правило — задача не подходит».
package main
import "strings"
type Filters struct {
DoneOnly bool
UndoneOnly bool
Contains string
}
func matches(t Task, f Filters) bool {
if f.DoneOnly && !t.Done {
return false
}
if f.UndoneOnly && t.Done {
return false
}
if f.Contains != "" && !strings.Contains(t.Title, f.Contains) {
return false
}
return true
}
Теперь сама фильтрация становится почти «тупой» и от этого прекрасной: она не содержит условий предметной области, она просто применяет предикат ко всем элементам и собирает результат.
package main
func filterTasks(tasks []Task, f Filters) []Task {
out := make([]Task, 0, len(tasks))
for _, t := range tasks {
if matches(t, f) {
out = append(out, t)
}
}
return out
}
Обратите внимание на make(..., 0, len(tasks)): мы заранее предполагаем, что «в худшем случае» пройдут все задачи, и просим Go выделить ёмкость под этот вариант. Это маленькая оптимизация, но она ещё и делает намерение ясным: «мы собираем новый список».
Валидация ввода и «fail fast»
Фильтры — это пользовательский ввод. Пользовательский ввод бывает прекрасен, но чаще он бывает «ой». Поэтому мы делаем валидацию до выполнения логики команды: если ввод конфликтный или некорректный, мы возвращаем ошибку и не лезем в бизнес-логику. Это экономит и время, и нервы, и количество загадочных состояний.
Главный конфликт сегодня — --done и --undone вместе. Мы явно проверяем это. Если позже добавятся другие фильтры (например, --since и --until), подход будет тот же: конфликт должен быть описан и проверен.
package main
import "fmt"
func validateFilters(f Filters) error {
if f.DoneOnly && f.UndoneOnly {
return fmt.Errorf("conflicting flags: --done and --undone")
}
return nil
}
Почему это лучше, чем «разрулить молча»? Потому что CLI — инструмент. Инструмент, который молча чинит за вас ввод, может «починить» его не так, как вы ожидали. А вы потом ещё и автоматизацию поверх этого напишете, и у вас появится робот, который уверенно делает неправильные вещи.
Встраиваем фильтры в команду list
Теперь соберём это в небольшой, цельный фрагмент: как флаги команды превращаются в Filters, валидируются, а затем применяются. Я буду показывать кусками по 5–10 строк, чтобы код оставался читабельным, но при этом всё складывалось в понятную картину.
Предположим, у нас уже есть каркас CLI с подкомандой list (мы разбирали flag.FlagSet ранее), и мы сейчас добавляем туда фильтры. Начнём с парсинга флагов:
package main
import "flag"
func parseListFlags(args []string) (Filters, string, error) {
fs := flag.NewFlagSet("list", flag.ContinueOnError)
done := fs.Bool("done", false, "show only done tasks")
undone := fs.Bool("undone", false, "show only undone tasks")
contains := fs.String("contains", "", "substring in title")
if err := fs.Parse(args); err != nil {
return Filters{}, "", err
}
return Filters{DoneOnly: *done, UndoneOnly: *undone, Contains: *contains}, fs.Arg(0), nil
}
Здесь есть один «странный» момент: я вернул ещё и fs.Arg(0). Это просто иллюстрация, что после флагов могут идти позиционные аргументы. В нашей конкретной команде list они могут быть не нужны; главное — понять, что Parse отделяет флаги от аргументов.
Теперь добавим --since. Для него удобнее использовать String, а потом парсить через parseSince, чтобы аккуратно контролировать ошибку:
package main
import "flag"
type ListOptions struct {
Filters Filters
Since string
Format string
}
func parseListOptions(args []string) (ListOptions, error) {
fs := flag.NewFlagSet("list", flag.ContinueOnError)
done := fs.Bool("done", false, "show only done tasks")
undone := fs.Bool("undone", false, "show only undone tasks")
contains := fs.String("contains", "", "substring in title")
since := fs.String("since", "", "created on/after date (YYYY-MM-DD)")
if err := fs.Parse(args); err != nil {
return ListOptions{}, err
}
return ListOptions{
Filters: Filters{DoneOnly: *done, UndoneOnly: *undone, Contains: *contains},
Since: *since,
}, nil
}
Обратите внимание: я не стал сразу добавлять time.Time в ListOptions. Почему? Потому что parseListOptions — это «сырой ввод», а time.Parse — это уже нормализация/валидация. Иногда удобно разделять эти стадии: сначала вытащили строки и булевы значения, потом отдельно превратили их в «настоящие данные».
Теперь соберём шаг валидации и применения. Для примера сделаем функцию, которая получает задачи (в реальном приложении — из хранилища), применяет фильтры и возвращает отфильтрованный список.
package main
func applyFilters(tasks []Task, opt ListOptions) ([]Task, error) {
if err := validateFilters(opt.Filters); err != nil {
return nil, err
}
sinceTime, sinceSet, err := parseSince(opt.Since)
if err != nil {
return nil, err
}
out := make([]Task, 0, len(tasks))
for _, t := range tasks {
if !matches(t, opt.Filters) {
continue
}
if sinceSet && t.CreatedAt.Before(sinceTime) {
continue
}
out = append(out, t)
}
return out, nil
}
Здесь видно важное архитектурное решение: фильтрация идёт по структурам Task, а не по строкам вывода. Значит, дальше мы можем этот список одинаково хорошо отдать и в таблицу, и в JSON, и в любую другую форму, не переписывая фильтры.
Чтобы было проще «почувствовать», как это работает, можно сделать мини-пример данных:
package main
import "time"
func seedTasks() []Task {
return []Task{
{ID: 1, Title: "Pay rent", Done: true, CreatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)},
{ID: 2, Title: "Buy milk", Done: false, CreatedAt: time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)},
{ID: 3, Title: "Read Go book", Done: false, CreatedAt: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC)},
}
}
Да, дата 2026-01-10 — это всего несколько дней назад относительно нашей текущей даты (пятница, 16 января 2026). Такие примеры хороши тем, что легко глазами проверить, что должно пройти фильтр --since=2026-01-01.
5. Типичные ошибки при проектировании фильтров
Ошибка №1: фильтрация «внутри рендера».
Часто новичок начинает делать что-то вроде: «если задача подходит — печатаем строку, иначе — пропускаем». В результате логика фильтров размазывается по fmt.Printf, и через неделю вы уже не можете добавить JSON-вывод без копипасты всех условий. Правильная привычка: фильтры всегда работают на []Task, а рендер получает уже готовый список.
Ошибка №2: «умные» дефолты, которые ведут себя как хотят.
Например, делать так, что --done --undone молча превращается в «покажи всё». Это может выглядеть удобно, но ломает диагностику: пользователь ошибся — а вы не сказали. Для CLI лучше быть строгим и понятным: конфликт — это ошибка ввода.
Ошибка №3: не различать «фильтр не задан» и «значение равно нулю».
У --since это особенно заметно. Если вы храните since time.Time и просто сравниваете с нулевым временем, код начинает выглядеть как магический ритуал: «если since не равен нулю». Гораздо читабельнее возвращать флаг sinceSet и проверять его явно.
Ошибка №4: продолжать выполнение после ошибки парсинга даты.
Иногда пишут: «если time.Parse упал — ну ладно, возьмём нулевую дату». Это превращает ошибочный ввод в непонятный результат. В CLI неверный формат даты должен приводить к ошибке, иначе пользователь никогда не научится вводить дату правильно.
Ошибка №5: пытаться сделать --contains «слишком умным» без договора.
Регистронезависимость, разбор слов, regex — всё это может быть полезно, но если вы не зафиксировали контракт, пользователи начнут гадать, как именно работает поиск. На старте лучше простое правило strings.Contains, а всё остальное — только осознанно и явно, с документацией.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ