## Context

`layout.BuildLinks` — чистая функция от плана: строит целевые пути, проверяет
выход за библиотеку и не трогает диск. Длину имени она не проверяет, поэтому
слишком длинное название доезжает до `Apply`, где `os.MkdirAll` или `os.Link`
получают от ядра `ENAMETOOLONG`. `worker.linkPlan` разбирает исход `Apply` двумя
ветками: `layout.ErrCollision` → `review`, всё остальное → `failed` с текстом
ошибки в `error_msg`. Системная ошибка попадает во вторую.

Путь «отказ построения → review» в проекте уже есть: ошибка `BuildLinks` уводит
задачу в `review` с кодом `build` (`worker/review.go`). То есть чинить надо не
маршрут, а место проверки и различимость причины.

Предел длины на ext4/xfs/btrfs — 255 **байт** на компонент (`NAME_MAX`), а не
символов: кириллическое название в UTF-8 тратит по два байта на букву и
упирается вдвое раньше, чем кажется по числу знаков.

## Goals / Non-Goals

**Goals:**

- Непомещающееся имя даёт `review` с доменной причиной, а не `failed` с текстом
  ядра.
- Отказ целиком: ни каталога, ни ссылки. Проверка стоит до первой операции с ФС.
- Причина различима кодом — по ней видно, чем этот вход в `review` отличается от
  коллизии и от прочих отказов построения.

**Non-Goals:**

- **Не обрезаем и не переименовываем название сами.** Это решение человека, и
  подсказка названия на ревью для него уже есть. Автоматическая обрезка дала бы
  папку, которую правило сходимости потом унаследует навсегда.
- **Не выясняем предел у ядра** (`pathconf(_PC_NAME_MAX)`, пробная запись).
  Проверка обязана отвечать до обращения к диску, а на разных подкаталогах
  ответы могут различаться.
- **Не проверяем предел длины пути целиком** (`PATH_MAX`, 4096 байт). Корни
  библиотек задаёт оператор, глубина у нас фиксированная (папка тайтла + папка
  сезона + файл), и трёх компонентов по 255 байт до 4096 не хватает.

## Decisions

**Решение 1. Проверка живёт в `BuildLinks`, по компонентам относительного
пути.** `BuildLinks` уже единственное место, где целевой путь собирается целиком
и проверяется на выход за библиотеку, — вторая проверка того же пути встаёт
рядом с первой. Проверяются компоненты **под корнем библиотеки**, а не весь путь:
корни задаёт оператор, и жаловаться на его каталоги раскладка не вправе.

**Решение 2. Предел — константа 255 байт с объяснением, откуда взята.**
`NAME_MAX` у ext4, xfs и btrfs — 255 байт; это предел самой распространённой
раскладки, а не универсальный закон. Значение стоит именованной константой в
`internal/layout` с комментарием, называющим источник. В конфиг не выносится:
настройка, которую никто не знает, как выставить, — это не гибкость.

**Решение 3. Своя ошибка `layout.ErrNameTooLong`, обёрнутая как остальные.**
`errors.Is` по ней ловит случай на любой глубине обёртки — так же, как
`ErrCollision`. Текст ошибки называет компонент и его длину в байтах: без числа
человек не поймёт, насколько сокращать.

**Решение 4. Свой код причины `name_too_long` рядом с `collision`.** Ошибка
`BuildLinks` сегодня даёт код `build` — он верен, но объединяет непомещающееся
имя с пустым названием и серией без номера. Код причины в этом проекте —
корреляционный ключ шага, и слепить три разных отказа в один значит потерять
возможность отличить чинимое подсказкой от нечинимого. Ветка встаёт в `linkPlan`
перед общей веткой `build`.

**Решение 5. Проверка длины идёт после проверки выхода за библиотеку.**
Путь, вылезший из песочницы, — это `critical`; сообщить о нём длиной имени
значило бы скрыть находку безопасности за косметической причиной. Порядок
проверок в цикле сохраняет приоритет.

**Решение 6. Превью получает проверку даром — и это довод за `BuildLinks`.**
Оба предпросмотра ревью (карточка и строка источника) строят пути тем же
`BuildLinks` (`worker/review.go`), поэтому вердикт на показе и вердикт на
применении совпадают по устройству, а не по договорённости. Цена — видимая:
непомещающееся имя даёт пустой предпросмотр, а панель действий при пустом
предпросмотре прячет «Применить» и печатает общий текст «Подтверди источник».
Баннер причины над карточкой при этом верен. Поведение не новое — так уже ведут
себя все отказы построения плана, — но это изменение делает его достижимым на
штатном входе.

## Risks / Trade-offs

- **Предел зашит и может не совпасть с реальной ФС.** На ФС с меньшим
  `NAME_MAX` (например, некоторые зашифрованные) имя пройдёт проверку и упрётся в
  ядро — тот же `failed`, что сегодня, но уже только на редкой раскладке. На ФС с
  бо́льшим пределом мы отказываем строже, чем нужно. Обе стороны названы в
  комментарии константы; лечение — правка константы, а не настройка.
- **Причина различима, но не самообъясняема.** Человек видит «имя не
  помещается» и длину — но не видит, на сколько байт сокращать после
  provider-тега и суффикса серии. Считать это за него — отдельная работа, здесь
  она не делается.
- **Изменение сдвигает часть нынешних `failed` в `review`.** Это и есть цель, но
  задачи, лежащие в `failed` с этой ошибкой прямо сейчас, сами туда не переедут и
  сверкой не подхватятся: из `failed` их выводит только `retry`, а он тянет
  полный цикл через `downloading` и распознавание. Обещания самоизлечения здесь
  нет.
- **Унаследованная база отказа по длине не даёт, и это арифметика, а не
  везение.** На чекпоинте эта строка стояла как признанный тупик: база берётся с
  диска, подсказка её не укорачивает, задача циклится. Попытка написать тест
  показала, что случай недостижим. Папка-якорь лежит на диске, значит её имя уже
  не длиннее предела; хвост имени файла — `" S02E01"` плюс расширение, 11 байт —
  не длиннее хвоста имени папки, `" ["` плюс provider-тег плюс `"]"`, минимум 11
  байт (`tmdbid-1`). Язык и флаги субтитров и двойные серии в `layout.PlanFile`
  из распознавания не приходят вовсе (`toLayoutPlan` их не заполняет), поэтому
  длиннее не станет. Свойство держит тест `TestApply_InheritedBaseAtLimitStillFits`:
  если суффиксы вырастут, он покраснеет раньше, чем задача упрётся в тупик.
