JavaRush /Курсы /Go SELF /Фильтры CLI: --done/...

Фильтры CLI: --done/ --undone, --since, --contains

Go SELF
51 уровень , 4 лекция
Открыта

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
только Done=true явно попросили
--undone
только Done=false явно попросили
--done --undone
ошибка конфликт, вероятная опечатка пользователя

Теперь — минимальная структура для фильтров. Важно: фильтры — это входные параметры этапа фильтрации, а не «логика, размазанная по коду».

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, а всё остальное — только осознанно и явно, с документацией.

1
Задача
Go SELF, 51 уровень, 4 лекция
Недоступна
Поиск в заголовке
Поиск в заголовке
1
Задача
Go SELF, 51 уровень, 4 лекция
Недоступна
Фильтр статуса
Фильтр статуса
1
Задача
Go SELF, 51 уровень, 4 лекция
Недоступна
Отбор по дате
Отбор по дате
1
Задача
Go SELF, 51 уровень, 4 лекция
Недоступна
Команда list
Команда list
1
Опрос
Форматы вывода, 51 уровень, 4 лекция
Недоступен
Форматы вывода
Форматы вывода
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ