## 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`: если суффиксы вырастут, он покраснеет раньше, чем задача упрётся в тупик.