манифесты стали данными, заведён convy sync

- убраны комментарии из suite.toml и .conventions.toml: файл, который
  машина переписывает, комментарий через круг не проносит; объяснения
  ушли в README рядом, который suite init теперь заводит
- удалена текстовая правка манифеста целиком — 520 строк ручного
  лексера TOML вместе со всем классом ошибок порчи данных
- запись идёт из структур энкодером; ключ, которого инструмент не
  знает, запись останавливает, а не теряется молча
- convy sync сверяет манифест и подводит под него раскладку файлов:
  чего не хватает — собирает, что осиротело — удаляет, копию с
  локальной частью не трогает никогда
This commit is contained in:
av
2026-07-28 09:45:10 +03:00
parent b6b0976c19
commit 92bd1f463d
18 changed files with 851 additions and 1055 deletions
+24 -18
View File
@@ -34,7 +34,7 @@ CLI для управления конвенциями. Модель здесь
```
internal/lang словарь: реестр «версия языка × естественный язык»
internal/source ссылки между уровнями: путь на диске, git-репозиторий
internal/manifest suite.toml и .conventions.toml — чтение и текстовая правка
internal/manifest suite.toml и .conventions.toml — чтение и запись
internal/doc разбор документа: шапка, области правил, блоки
internal/suite сборка набора в память, отбор слоёв под компонент
internal/project сборка копий в проекте: разделы, маркер, READING.md
@@ -59,11 +59,14 @@ internal/cli команды, диалог, два режима
- **Что инструмент пишет, инструмент принимает.** Набор, созданный `suite init`,
`add` и `rule`, обязан проходить `suite check` без правок. Это проверяет
`checkClean` в `internal/cli`; ломать инвариант нельзя.
- **Манифест правится текстом, а не энкодером.** В `suite.toml` комментариев
больше, чем данных. `manifest.AddEntry` вставляет запись, подстраиваясь под
порядок таблицы: отсортированную по алфавиту держит отсортированной,
упорядоченную вручную дополняет в конец. Комментарий, отделённый пустой
строкой, принадлежит таблице **ниже** себя.
- **Манифест — данные.** Оба манифеста декодируются в структуры и пишутся
обратно энкодером целиком. Комментариев в них нет: файл, который машина
переписывает, комментарий через круг не проносит, и вид, что проносит, стоит
этого комментария в день, когда никто не смотрит. Объяснения — в соседних
файлах, которых ни одна команда не касается.
- **Непонятый ключ останавливает запись.** Раз запись идёт из структур, ключ,
которого в них нет, при сохранении исчез бы. `manifest.save` отказывается,
называя ключ: это единственный исход, который его не теряет и не прячет.
- **Разбор опирается на разметку, а не на суждение.** Область правила — от
заголовка до следующего заголовка любого уровня. Метка открывает блок только
первой в абзаце и полужирным. Огороженные блоки кода исключаются везде;
@@ -71,11 +74,6 @@ internal/cli команды, диалог, два режима
пути канона. Маркер локальной части — то же самое: `doc.LocalMarker` один на
весь инструмент, `doc.Marker()` пропускает огороженные блоки, потому что
конвенция о ведении копий этот маркер цитирует.
- **Манифест читается так, как он записан.** Решётка внутри строки не открывает
комментарий, скобка внутри комментария не закрывает массив, имя внутри
комментария не подписка. Регуляркой по сырым строкам это не берётся —
`splitComment` и `scanCode` в `internal/manifest`. Превращение комментария в
данные — единственная ошибка, из которой нет дороги назад.
- **Уровень называется ссылкой, а не путём.** Проект ссылается на набор, набор
на язык; `source.Ref` разбирает ссылку, `source.Open` отдаёт директорию,
которую можно читать. Транспортов два, но `Kind` — перечисление, а не булево:
@@ -124,9 +122,16 @@ internal/cli команды, диалог, два режима
Проверка гоняется на каждой правке и в сеть ходить не должна. Пропуск
объявляется строкой в выводе: молча не выполненная проверка читается ровно
как пройденная.
- **Всё, что попадает в манифест, проходит через `manifest.Quote`.** Обратный
слэш в пути — обычный случай, на котором инструмент перестаёт читать файл,
который сам записал.
- **Комментариев в манифестах не будет.** Пробовали держать их текстовой
правкой — вышло четыре случая порчи данных подряд: комментарий с кавычками
становился подпиской, скобка в комментарии обрезала массив. Формат с
сохранением комментариев при записи (`go-toml-edit`, YAML через `yaml.Node`)
отвергнут как усложнение под задачу, которой нет: манифест машинный.
- **`sync` — о наборе файлов, `pull`о содержимом.** `pull` берёт текст всех
подписок заново, и оставленный им дифф и есть смысл запуска. `sync` сверяет
манифест и подводит под него раскладку: чего не хватает — собирает, что
осиротело — удаляет. Копию с непустой локальной частью не удаляет никогда и
завершается с ошибкой, пока она лежит.
- **Позиционный аргумент отсекается явно.** `flag` прекращает разбор на первом
не-флаге, поэтому лишний аргумент не просто лежит без дела — он прячет все
флаги после себя. `noStrayArgs` в командах без позиционных, ручное снятие
@@ -203,11 +208,12 @@ Commits и `Co-Authored-By` не используются.
## Состояние
Наборная сторона: `init`, `add`, `rule`, `retire`, `list`, `check`.
Проектная: `init`, `add`, `pull`, `list`, `check`. Обе стороны закончены по
тому, что намечено в `TOOL.md`.
Проектная: `init`, `add`, `pull`, `sync`, `list`, `check`. Обе стороны
закончены по тому, что намечено в `TOOL.md`; `sync` в `TOOL.md` не значится и
заведён сверх него.
Отбор слоёв под компонент — один на обе стороны: `suite.Assemble`. `suite list`
показывает, что взял бы компонент, `convy pull` то же самое пишет в файл;
разъехаться они не должны.
показывает, что взял бы компонент, `convy pull` и `convy sync` то же самое
пишут в файл; разъехаться они не должны.
Линтеров и CI нет.
+51 -2
View File
@@ -84,7 +84,8 @@ go build -o convy .
В проекте:
convy init подключить конвенции: источник и первый компонент
convy add <тема> подписаться и собрать
convy pull пересобрать подписанное
convy pull пересобрать подписанное, текст и всё
convy sync привести файлы в соответствие манифесту
convy list что подключено и что ещё есть в наборе
convy check проверить форму того, что здесь
```
@@ -180,6 +181,52 @@ topics = ["logging", "time"]
`postgres` действуют вместе, это разные таблицы одного сервиса. Два языка не
действуют вместе никогда — ради этого компонент и заведён.
## Манифесты — данные, а не текст
Оба манифеста инструмент и читает, и переписывает целиком. Поэтому комментариев
в них нет: файл, который машина переписывает, комментарий через круг не
проносит, а вид, что проносит, стоит этого комментария в день, когда никто не
смотрит. Объяснения живут в соседних файлах, которых ни одна команда не
касается: `convy suite init` заводит рядом `README.md` и пишет их туда.
Ключ, которого инструмент не знает, при записи потерялся бы. Поэтому он не
пишет вовсе:
```
$ convy suite add --topic time --about "время" --prefix TIME --title "Время"
suite.toml holds 1 key the tool does not know (language.descriptoin); a write
goes out of what the tool understands, so the key would be dropped — fix the
spelling first
```
## Манифест — источник истины
`.conventions.toml` правится руками так же законно, как командой. Дальше
раскладку под него подводит `sync`:
```
$ convy sync --dry-run
backend → docs/conventions
+ docs/conventions/errors.md subscribed, and no file
- docs/conventions/logging.md nothing subscribes to "logging"
2 files would change; run without --dry-run to do it
```
Деление с `pull` проходит по тому, о чём команда. `pull`о содержимом:
берёт текст всех подписок заново, и оставленный им дифф и есть смысл запуска.
`sync` — о наборе файлов: чего манифест требует и нет — собирается, что есть и
никому не нужно — удаляется.
Копия с локальной частью не удаляется никогда: ниже маркера лежит
единственное, чего нет больше нигде. Такая копия называется в отчёте, и `sync`
завершается с ошибкой, пока её не убрали руками или не подписались снова.
Перед тем как что-то трогать, `sync` сверяет манифест: подписка на снятую или
несуществующую тему, тема дважды, два языка в одном компоненте, тема без
подходящего слоя, общая директория у двух компонентов. Находки называются
разом, и ничего не пишется.
Копия плоская, файл на тему. Первый слой — сам документ; каждый следующий
становится его разделом, и заголовки внутри опускаются на уровень: слой
реализует и сужает базу, а не стоит рядом с ней. Строка о версии языка
@@ -255,4 +302,6 @@ errors: 1, warnings: 0
- не переносит правки из проекта в набор: операция ручная и редкая;
- не перенумеровывает правила: номер — идентификатор, а не позиция;
- не кэширует источник: клон делается заново и удаляется;
- не знает нескольких наборов сразу: `source` в проекте один.
- не знает нескольких наборов сразу: `source` в проекте один;
- не хранит комментарии в манифестах: они данные, а объяснения — в соседних
файлах.
+2 -12
View File
@@ -3,7 +3,6 @@ package cli
import (
"flag"
"fmt"
"os"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
@@ -96,17 +95,8 @@ func runAdd(env Env, args []string) ExitCode {
// The manifest is written after the file: a subscription recorded against
// an assembly that failed would send the next pull looking for a copy that
// was never made.
source, err := os.ReadFile(o.Manifest.Path)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
updated, err := manifest.AddToList(source, "components."+name, "topics", topic)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
if err := os.WriteFile(o.Manifest.Path, updated, 0o644); err != nil {
o.Manifest.Subscribe(name, topic)
if err := o.Manifest.Save(); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
+4 -1
View File
@@ -55,6 +55,8 @@ func Run(env Env, args []string) ExitCode {
return runAdd(env, args[1:])
case "pull":
return runPull(env, args[1:])
case "sync":
return runSync(env, args[1:])
case "list":
return runList(env, args[1:])
case "check":
@@ -100,7 +102,8 @@ func usage(w io.Writer) {
In a project:
convy init wire up conventions: the source and the first component
convy add <topic> subscribe and assemble
convy pull reassemble what is subscribed
convy pull reassemble what is subscribed, text and all
convy sync make the files follow the manifest, and say what is off
convy list what is wired up and what else the suite has
convy check check the form of what is here
+18 -55
View File
@@ -5,42 +5,16 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/source"
"git.vakhrushev.me/av/convy/internal/suite"
)
// projectSkeleton is what a project manifest starts as. It is written as text
// with its comments in place for the same reason the suite manifest is: the
// file is read far more often than a tool touches it, and what a component is
// for is not deducible from three keys.
const projectSkeleton = `# What this repository takes from a conventions suite.
#
# Two manifests exist in the model, each named after what it describes:
# suite.toml in a suite describes the suite itself; .conventions.toml here
# describes the subscription — where the copies come from and who takes what.
#
# Copies are committed. Nothing is fetched on the fly, and the answer to "what
# did it look like last time" is given by git rather than by a lock file.
# Where the copies come from: a path on disk, relative to this file or absolute,
# or a git repository over http or https. A trailing #branch, #tag or #commit
# pins a revision.
source = %s
# ─── Components ─────────────────────────────────────────────────────────────
#
# A component is a region of the repository where every selected layer holds at
# once: one language, one set of tools, one kind of application. Each one has
# its own directory, and the directories differ — that is the only thing that
# tells two copies of one topic apart.
#
# lang and stack choose the layers: a layer travels when the axis it declares is
# among them, and a layer declaring no axis travels always. topics is the
# subscription itself, by the names the suite gives its topics.
`
// A project manifest is data, and the tool rewrites it whole on every
// subscription. So it carries no comment: what a component is for is said in
// the documentation, which stays put, rather than in a file that a machine
// re-encodes behind the author's back.
func runInit(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy init", flag.ContinueOnError)
@@ -105,27 +79,25 @@ func runInit(env Env, args []string) ExitCode {
return Failed
}
entries := [][2]string{{"dir", manifest.Quote(given["dir"])}}
if list := split(given["lang"]); len(list) > 0 {
entries = append(entries, [2]string{"lang", array(list)})
}
if list := split(given["stack"]); len(list) > 0 {
entries = append(entries, [2]string{"stack", array(list)})
}
entries = append(entries, [2]string{"topics", "[]"})
body, err := manifest.AddTable(fmt.Appendf(nil, projectSkeleton, manifest.Quote(given["source"])),
"components."+given["component"], entries)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
if err := os.MkdirAll(where, 0o755); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
name := filepath.Join(where, manifest.ProjectName)
if err := os.WriteFile(name, body, 0o644); err != nil {
m := &manifest.Project{
Source: given["source"],
Components: map[string]manifest.Component{
given["component"]: {
Dir: given["dir"],
Lang: split(given["lang"]),
Stack: split(given["stack"]),
Topics: []string{},
},
},
Path: name,
Root: where,
}
if err := m.Save(); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
@@ -181,12 +153,3 @@ func initFields() []Field {
Optional: true,
}}
}
// array writes a TOML array of strings.
func array(values []string) string {
parts := make([]string, len(values))
for i, v := range values {
parts[i] = manifest.Quote(v)
}
return "[" + strings.Join(parts, ", ") + "]"
}
+45 -20
View File
@@ -11,8 +11,22 @@ import (
"git.vakhrushev.me/av/convy/internal/check"
"git.vakhrushev.me/av/convy/internal/cli"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/manifest"
)
// addComponent puts one more component into the project manifest.
func addComponent(t *testing.T, root, name string, c manifest.Component) {
t.Helper()
m, err := manifest.LoadProject(root)
if err != nil {
t.Fatal(err)
}
m.Components[name] = c
if err := m.Save(); err != nil {
t.Fatal(err)
}
}
// readingGuide stands in for what a suite puts next to its copies: the short
// account of the language, naming every word the version line names.
const readingGuide = `# Как читать конвенцию
@@ -43,21 +57,44 @@ func subscribable(t *testing.T) string {
**ДОЛЖЕН.** Текущее время приходит из `+"`store.Now()`"+`.
**ПОЧЕМУ.** Единая точка даёт гарантированный UTC и один формат.
`)
// A topic whose only layer sits on an axis: a component of another stack
// takes nothing of it at all. The canon has such topics, and a fixture
// where every topic has a base layer would never exercise that.
run(t, root, "", false, "suite", "add", "--topic", "web-ui", "--about", "веб-UI",
"--prefix", "HTMX", "--stack", "htmx", "--title", "Веб-UI на htmx")
appendRules(t, root, "conventions/stack/htmx/web-ui.md", `
### HTMX-1. Партиал отвечает фрагментом, а не страницей
**ДОЛЖЕН.** Обработчик свопа возвращает только заменяемый фрагмент.
**ПОЧЕМУ.** Страница целиком заставляет браузер выбросить состояние формы.
`)
name := filepath.Join(root, "READING.md")
if err := os.WriteFile(name, []byte(readingGuide), 0o644); err != nil {
t.Fatal(err)
}
body := read(t, root, "suite.toml")
body = strings.Replace(body, `# reading = "READING.md"`, `reading = "READING.md"`, 1)
if err := os.WriteFile(filepath.Join(root, "suite.toml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
edit(t, root, func(m *manifest.Manifest) { m.Language.Reading = "READING.md" })
checkClean(t, root)
return root
}
// edit changes the suite manifest the way the tool does: through the struct,
// because the file is data and carries nothing else to preserve.
func edit(t *testing.T, root string, change func(*manifest.Manifest)) {
t.Helper()
m, err := manifest.Load(root)
if err != nil {
t.Fatal(err)
}
change(m)
if err := m.Save(); err != nil {
t.Fatal(err)
}
}
// wired builds a project taking from that suite, and returns its root.
func wired(t *testing.T, suiteRoot string, args ...string) string {
t.Helper()
@@ -260,11 +297,7 @@ func TestAddTakesTheTopicBeforeTheFlags(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "backend/docs", "--lang", "go")
body := read(t, root, ".conventions.toml")
body += "\n[components.web]\ndir = \"web/docs\"\ntopics = []\n"
if err := os.WriteFile(filepath.Join(root, ".conventions.toml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
addComponent(t, root, "web", manifest.Component{Dir: "web/docs", Topics: []string{}})
code, out := run(t, root, "", false, "add", "logging", "--for", "web")
if code != cli.OK {
@@ -285,11 +318,7 @@ func TestTwoComponentsMayNotShareADirectory(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
body := read(t, root, ".conventions.toml")
body += "\n[components.web]\ndir = \"docs/conventions\"\ntopics = []\n"
if err := os.WriteFile(filepath.Join(root, ".conventions.toml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
addComponent(t, root, "web", manifest.Component{Dir: "docs/conventions", Topics: []string{}})
code, out := run(t, root, "", false, "pull")
if code == cli.OK {
@@ -340,11 +369,7 @@ func TestTheLanguageMayLiveApartFromTheSuite(t *testing.T) {
if err := os.Rename(filepath.Join(suiteRoot, "READING.md"), filepath.Join(apart, "READING.md")); err != nil {
t.Fatal(err)
}
body := read(t, suiteRoot, "suite.toml")
body = strings.Replace(body, `reading = "READING.md"`, "source = \"../language\"\nreading = \"READING.md\"", 1)
if err := os.WriteFile(filepath.Join(suiteRoot, "suite.toml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
edit(t, suiteRoot, func(m *manifest.Manifest) { m.Language.Source = "../language" })
checkClean(t, suiteRoot)
// A check that did not run says so: silence would read as a check passed.
+3 -16
View File
@@ -252,27 +252,14 @@ func writeConvention(env Env, s *suite.Suite, given map[string]string) ExitCode
return Usage
}
source, err := os.ReadFile(s.Manifest.Path)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
if !s.Manifest.TopicLive(given["topic"]) {
source, err = manifest.AddEntry(source, "topics.live", given["topic"], given["about"])
if err != nil {
fmt.Fprintf(env.Err, "%s was written, but the manifest was not: %s\n", rel, err)
return Failed
}
s.Manifest.Topics.Add(given["topic"], given["about"])
}
source, err = manifest.AddEntry(source, "prefixes.live", given["prefix"], rel)
if err != nil {
s.Manifest.Prefixes.Add(given["prefix"], rel)
if err := s.Manifest.Save(); err != nil {
fmt.Fprintf(env.Err, "%s was written, but the manifest was not: %s\n", rel, err)
return Failed
}
if err := os.WriteFile(s.Manifest.Path, source, 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
fmt.Fprintf(env.Out, "\ncreated %s\n", rel)
fmt.Fprintf(env.Out, "updated %s: prefix %s", manifest.Name, given["prefix"])
+59 -39
View File
@@ -10,52 +10,56 @@ import (
"git.vakhrushev.me/av/convy/internal/manifest"
)
// manifestSkeleton is what a suite starts as. The tables stand empty but
// present: `convy suite add` splices entries into them, and a table that is not
// there is a table an edit cannot find.
// A suite starts as a manifest holding only what it knows about itself, and a
// README next to it holding everything a person needs to know to fill it in.
//
// The two documents about the language are left commented out on purpose. The
// suite is expected to carry them, but the tool cannot author them, and a
// manifest pointing at a file that does not exist is a manifest that fails its
// own check on the first run.
const manifestSkeleton = `# The manifest of a conventions suite.
#
# Two manifests exist in the model, each named after what it describes:
# suite.toml here, in the suite, describes the suite itself; .conventions.toml
# in a project describes what that project subscribed to. Which of the two lies
# next to you tells you where you are.
// The two are split because the manifest is data the tool rewrites on every
// `suite add` and every `suite retire`, while the README is prose nothing
// touches. Keeping the explanations inside the manifest would mean losing them
// the first time a command wrote the file.
//
// The README is a starting point, in the tool's own language, for its author to
// replace. What the suite itself is about, only its author knows.
const readmeSkeleton = `# Conventions suite
[language]
version = %d
lang = "%s"
# description = "LANGUAGE.md" # the full account of the language, stays with the author
# reading = "READING.md" # the short guide for a reader, travels into every copy
The rules live in ` + "`conventions/`" + `. What each file is and what it may do is
settled by the conventions language; this README is the place to say what this
particular suite is for and how it is kept.
# ─── Topics ─────────────────────────────────────────────────────────────────
#
# A topic is a set of rules about one focus of development, and the unit of
# subscription. The value is the one line about what the topic is for; the
# table of conventions in a consumer's README is built out of it.
## The manifest
[topics.live]
` + "`suite.toml`" + ` holds the identity of the suite: the language its rules are
written in, its topics and its rule prefixes. It is written by ` + "`convy`" + ` and
carries no comments — a command rewrites the whole file, and a comment would not
survive that. Explanations belong here instead.
# Retired names land here together with a reason and a date, so that they can
# never be handed to another topic: the name lives on in foreign repositories.
## Topics
[topics.retired]
A topic is a set of rules about one focus of development — time, configuration,
the database schema — and it is the unit of subscription: a consumer takes it
whole. A name is never renamed and never reissued, because it lives on in
foreign repositories: in the ` + "`origin:`" + ` header of every copy and in the
subscription of every consumer.
# ─── Rule prefixes ──────────────────────────────────────────────────────────
#
# A prefix is four uppercase Latin letters, unique across the suite, chosen for
# a file rather than derived by a formula. The letter X is reserved for the
# local rules of consumers and is never taken here. Paths are given from the
# root of the repository.
Since the name is permanent, a topic is named after a decision and whom it
addresses rather than after the role some part of today's project plays.
[prefixes.live]
## Prefixes
# Prefixes of deleted and split files land here, likewise never to be reissued.
A prefix is four uppercase Latin letters, unique across the suite, chosen for a
file rather than derived by a formula. Rules are addressed by identifier —
` + "`KEYS-5`" + ` — with no path, so the identifier survives a file moving between
axes. A prefix is never reissued either.
[prefixes.retired]
The letter X in first position is reserved for the local rules of consuming
repositories. The suite never takes it, so a local prefix can never collide with
a future one here.
## Retirement
Nothing leaves the manifest. A topic or a prefix that is done moves to the
retired half together with a reason and a date, so that the name can never be
handed to something else.
`
func runSuiteInit(env Env, args []string) ExitCode {
@@ -108,17 +112,33 @@ func runSuiteInit(env Env, args []string) ExitCode {
return Usage
}
body := fmt.Sprintf(manifestSkeleton, *version, given["lang"])
name := filepath.Join(root, manifest.Name)
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
m := &manifest.Manifest{
Language: manifest.Language{Version: *version, Lang: given["lang"]},
Path: name,
}
if err := m.Save(); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
fmt.Fprintf(env.Out, "\ncreated %s\ncreated %s\n", name, filepath.Join(root, "conventions"))
// The README is written only when there is none: it is a starting point,
// and a starting point that overwrites what somebody already wrote is not
// one.
readme := filepath.Join(root, "README.md")
if !exists(readme) {
if err := os.WriteFile(readme, []byte(readmeSkeleton), 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
fmt.Fprintf(env.Out, "created %s\n", readme)
}
fmt.Fprintf(env.Out, "\nthe suite speaks %s, conventions language version %d\n", given["lang"], *version)
fmt.Fprint(env.Out, `
next:
rewrite README.md — it says what a topic and a prefix are, not what this suite is for
write the two documents about the language and name them in [language]
convy suite add add the first convention
convy suite check verify the suite holds together
+4 -33
View File
@@ -12,7 +12,6 @@ import (
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/suite"
)
@@ -260,23 +259,9 @@ func retirePrefix(env Env, d *dialogue, s *suite.Suite, given map[string]string,
}
}
source, err := os.ReadFile(s.Manifest.Path)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
source, err = manifest.RemoveEntry(source, "prefixes.live", prefix)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
note := fmt.Sprintf("%s, was %s: %s", when, target.Path, given["reason"])
source, err = manifest.AddEntry(source, "prefixes.retired", prefix, note)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
if err := os.WriteFile(s.Manifest.Path, source, 0o644); err != nil {
s.Manifest.Prefixes.Retire(prefix, note)
if err := s.Manifest.Save(); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
@@ -314,22 +299,8 @@ func retireTopic(env Env, d *dialogue, s *suite.Suite, given map[string]string,
}
}
source, err := os.ReadFile(s.Manifest.Path)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
source, err = manifest.RemoveEntry(source, "topics.live", topic)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
source, err = manifest.AddEntry(source, "topics.retired", topic, when+": "+given["reason"])
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
if err := os.WriteFile(s.Manifest.Path, source, 0o644); err != nil {
s.Manifest.Topics.Retire(topic, when+": "+given["reason"])
if err := s.Manifest.Save(); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
+215
View File
@@ -0,0 +1,215 @@
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/project"
)
// convy sync makes the files agree with the manifest. The manifest is the
// truth: it says which components exist, where they write and what each takes,
// and everything under those directories follows from that.
//
// It divides from pull by what it is about. pull is about the contents of a
// copy — it takes the text of every subscription afresh, and the diff it leaves
// is the point of running it. sync is about the set of files: what the manifest
// calls for and is not there gets assembled, what is there and nothing calls
// for gets reported and, when nothing of the repository is in it, removed.
//
// A copy carrying a local part is never removed. Below the marker is the one
// thing in the directory that exists nowhere else, and a command that tidies up
// has no business deciding it is spent.
func runSync(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy sync", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the project; it is looked up upwards by default")
forComponent := fs.String("for", "", "component to bring in line; every one of them by default")
dry := fs.Bool("dry-run", false, "say what would change and change nothing")
if err := fs.Parse(args); err != nil {
return Usage
}
if code := noStrayArgs(env, "convy sync", fs.Args()); code != OK {
return code
}
o, code := openProject(env, *root)
if code != OK {
return code
}
defer o.Close()
names, code := components(env, o.Manifest, *forComponent)
if code != OK {
return code
}
problems := validateManifest(o, names)
for _, p := range problems {
fmt.Fprintf(env.Err, "%s\n", p)
}
if len(problems) > 0 {
fmt.Fprintln(env.Err, "\nthe manifest is what the layout follows, so nothing was touched")
return Failed
}
changed, left := 0, 0
for i, name := range names {
c := o.Manifest.Components[name]
if i > 0 {
fmt.Fprintln(env.Out)
}
fmt.Fprintf(env.Out, "%s → %s\n", name, c.Dir)
n, stuck, code := syncComponent(env, o, c, *dry)
if code != OK {
return code
}
changed += n
left += stuck
}
fmt.Fprintln(env.Out)
switch {
case *dry && changed > 0:
fmt.Fprintf(env.Out, "%s would change; run without --dry-run to do it\n", plural(changed, "file"))
case changed > 0:
fmt.Fprintf(env.Out, "%s changed; convy pull takes the text of the rest afresh\n", plural(changed, "file"))
case left == 0:
fmt.Fprintln(env.Out, "the layout already follows the manifest")
}
if left > 0 {
fmt.Fprintf(env.Out, "%s left alone: nothing subscribes to it and it holds a local part\n", plural(left, "file"))
return Failed
}
return OK
}
// validate checks the manifest against itself and against the suite. Everything
// wrong is reported at once: being sent back one line at a time is the worst way
// to learn what a file wants.
func validateManifest(o *opened, names []string) []string {
var out []string
if err := distinctDirs(o.Manifest); err != nil {
out = append(out, err.Error())
}
for _, name := range names {
c := o.Manifest.Components[name]
if c.Dir == "" {
out = append(out, fmt.Sprintf("the component %q names no dir, and a copy has to be written somewhere", name))
}
if len(c.Lang) > 1 {
out = append(out, fmt.Sprintf("the component %q declares two languages (%s): a line of code is written in one of them, and a component is the region where every chosen layer holds at once — split it",
name, strings.Join(c.Lang, ", ")))
}
seen := make(map[string]bool, len(c.Topics))
for _, topic := range c.Topics {
switch {
case seen[topic]:
out = append(out, fmt.Sprintf("the component %q takes %q twice", name, topic))
case o.Suite.Manifest.TopicRetired(topic):
out = append(out, fmt.Sprintf("the component %q takes %q, which the suite has retired: %s",
name, topic, o.Suite.Manifest.Topics.Retired[topic]))
case !o.Suite.Manifest.TopicLive(topic):
out = append(out, fmt.Sprintf("the component %q takes %q, and the suite declares no such topic", name, topic))
default:
if taken, _ := o.Suite.Assemble(topic, project.Axis(c)); len(taken) == 0 {
out = append(out, fmt.Sprintf("the component %q takes %q, and no layer of it fits this component", name, topic))
}
}
seen[topic] = true
}
}
return out
}
// syncComponent brings one directory in line. It returns how many files moved
// and how many it would not touch.
func syncComponent(env Env, o *opened, c manifest.Component, dry bool) (changed, left int, code ExitCode) {
for _, topic := range c.Topics {
rel := filepath.ToSlash(filepath.Join(c.Dir, topic+".md"))
if exists(filepath.Join(o.Root, filepath.FromSlash(rel))) {
continue
}
changed++
if dry {
fmt.Fprintf(env.Out, " + %-28s subscribed, and no file\n", rel)
continue
}
made, err := project.Assemble(o.Suite, o.Root, c, topic)
if err != nil {
fmt.Fprintf(env.Err, " %s: %s\n", topic, err)
return changed, left, Failed
}
fmt.Fprintf(env.Out, " + %-28s %s\n", made.Path, plural(len(made.Layers), "layer"))
}
orphans, err := orphaned(o.Root, c)
if err != nil {
fmt.Fprintln(env.Err, err)
return changed, left, Failed
}
for _, orphan := range orphans {
switch {
case orphan.local:
left++
fmt.Fprintf(env.Out, " ! %-28s nothing subscribes to %q, and it carries a local part: remove it by hand or subscribe again\n",
orphan.path, orphan.topic)
case dry:
changed++
fmt.Fprintf(env.Out, " - %-28s nothing subscribes to %q\n", orphan.path, orphan.topic)
default:
if err := os.Remove(filepath.Join(o.Root, filepath.FromSlash(orphan.path))); err != nil {
fmt.Fprintln(env.Err, err)
return changed, left, Failed
}
changed++
fmt.Fprintf(env.Out, " - %-28s nothing subscribes to %q\n", orphan.path, orphan.topic)
}
}
if !dry {
guide, err := project.Reading(o.LangRoot, o.Suite.Manifest.Language.Reading, o.Root, c.Dir)
if err != nil {
fmt.Fprintf(env.Err, " %s\n", err)
return changed, left, Failed
}
fmt.Fprintf(env.Out, " = %-28s the guide to reading a rule\n", guide)
}
return changed, left, OK
}
// orphan is a copy in a component directory that the manifest does not call for.
type orphan struct {
path string
topic string
local bool
}
// orphaned finds the copies nothing subscribes to. What is a copy is decided by
// the origin key: README.md belongs to the repository, READING.md belongs to the
// suite, and a file whose origin was taken away has become a document of the
// repository — none of the three is anyone's to remove.
func orphaned(root string, c manifest.Component) ([]orphan, error) {
docs, broken := copies(root, c.Dir)
if len(broken) > 0 {
return nil, broken[0]
}
var out []orphan
for _, d := range docs {
if c.Subscribed(d.Front.Origin) {
continue
}
local := ""
if at := d.Marker(); at > 0 {
local = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(d.Below(at)), project.LocalMarker))
}
out = append(out, orphan{path: d.Path, topic: d.Front.Origin, local: local != ""})
}
return out, nil
}
+166
View File
@@ -0,0 +1,166 @@
package cli_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/cli"
"git.vakhrushev.me/av/convy/internal/manifest"
)
// subscribe edits the project manifest the way a person would: by hand, in the
// file. That is the whole premise of sync — the manifest is the truth, and the
// layout follows it.
func subscribe(t *testing.T, root string, topics ...string) {
t.Helper()
m, err := manifest.LoadProject(root)
if err != nil {
t.Fatal(err)
}
c := m.Components["backend"]
c.Topics = topics
m.Components["backend"] = c
if err := m.Save(); err != nil {
t.Fatal(err)
}
}
func TestSyncAssemblesWhatIsMissingAndRemovesWhatIsOrphaned(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
run(t, root, "", false, "add", "time")
// The manifest is edited by hand: time goes, logging comes.
subscribe(t, root, "logging")
code, out := run(t, root, "", false, "sync", "--dry-run")
if code != cli.OK {
t.Fatalf("the dry run returned %d: %s", code, out)
}
if !strings.Contains(out, "would change") {
t.Errorf("the dry run promised nothing:\n%s", out)
}
if !exists(t, root, "docs/conventions/time.md") {
t.Errorf("the dry run removed a file")
}
if exists(t, root, "docs/conventions/logging.md") {
t.Errorf("the dry run assembled a file")
}
code, out = run(t, root, "", false, "sync")
if code != cli.OK {
t.Fatalf("sync returned %d: %s", code, out)
}
if !exists(t, root, "docs/conventions/logging.md") {
t.Errorf("the subscribed topic was not assembled:\n%s", out)
}
if exists(t, root, "docs/conventions/time.md") {
t.Errorf("the copy nothing subscribes to stayed:\n%s", out)
}
// Run again: nothing left to do, and it says so.
code, out = run(t, root, "", false, "sync")
if code != cli.OK || !strings.Contains(out, "already follows the manifest") {
t.Errorf("a second sync found work to do:\n%s", out)
}
}
// Below the marker is the one thing in the directory that exists nowhere else.
func TestSyncLeavesAnOrphanCarryingALocalPart(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
run(t, root, "", false, "add", "time")
name := filepath.Join(root, "docs", "conventions", "time.md")
body := read(t, root, "docs/conventions/time.md")
body += "\nTIME-1 — МЕХАНИЗИРОВАНО: `internal/archrules`.\n"
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
subscribe(t, root)
code, out := run(t, root, "", false, "sync")
if code == cli.OK {
t.Fatalf("an orphan with a local part went unremarked:\n%s", out)
}
if !strings.Contains(out, "local part") {
t.Errorf("the report does not say why the file was left:\n%s", out)
}
if !exists(t, root, "docs/conventions/time.md") {
t.Fatalf("the local part was destroyed:\n%s", out)
}
}
// The manifest is the truth, so a manifest that does not hold together stops
// the command before anything is written.
func TestSyncValidatesTheManifestBeforeTouchingAnything(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
cases := []struct {
name string
change func(*manifest.Project)
want string
}{{
name: "a topic the suite does not declare",
change: func(m *manifest.Project) { subscribeTo(m, "billing") },
want: "no such topic",
}, {
name: "the same topic twice",
change: func(m *manifest.Project) { subscribeTo(m, "time", "time") },
want: "twice",
}, {
name: "two languages in one component",
change: func(m *manifest.Project) {
c := m.Components["backend"]
c.Lang = []string{"go", "javascript"}
m.Components["backend"] = c
},
want: "declares two languages",
}, {
name: "a topic no layer of which fits",
change: func(m *manifest.Project) {
// web-ui lives on the htmx stack only, and this component is on
// no stack at all.
subscribeTo(m, "web-ui")
},
want: "no layer of it fits",
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m, err := manifest.LoadProject(root)
if err != nil {
t.Fatal(err)
}
tc.change(m)
if err := m.Save(); err != nil {
t.Fatal(err)
}
code, out := run(t, root, "", false, "sync")
if code == cli.OK {
t.Fatalf("the manifest went through:\n%s", out)
}
if !strings.Contains(out, tc.want) {
t.Errorf("the report does not say %q:\n%s", tc.want, out)
}
if !strings.Contains(out, "nothing was touched") {
t.Errorf("the report does not say it wrote nothing:\n%s", out)
}
})
}
}
func subscribeTo(m *manifest.Project, topics ...string) {
c := m.Components["backend"]
c.Topics = topics
m.Components["backend"] = c
}
func exists(t *testing.T, parts ...string) bool {
t.Helper()
_, err := os.Stat(filepath.Join(parts...))
return err == nil
}
-183
View File
@@ -1,183 +0,0 @@
package manifest
import (
"fmt"
"regexp"
"slices"
"sort"
"strings"
)
// The manifest is edited as text rather than decoded and written back.
//
// suite.toml carries more comment than data — the reasoning behind every topic
// and every prefix lives there, and an encoder would drop all of it and reorder
// what is left. So an entry is spliced into the source, and everything the
// author wrote around it survives untouched.
var (
tableRe = regexp.MustCompile(`^\s*\[([^\]]+)\]\s*$`)
keyRe = regexp.MustCompile(`^\s*("[^"]+"|[A-Za-z0-9_-]+)\s*=`)
bareRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
)
// AddEntry splices key = "value" into the given table of a TOML source.
//
// Where the entry lands follows what the table already does: a table whose keys
// are in alphabetical order keeps it, and one ordered by hand — by directory,
// by age, by whatever the author meant — gets the entry appended, because
// guessing at that order would scatter it.
func AddEntry(source []byte, table, key, value string) ([]byte, error) {
lines := strings.Split(string(source), "\n")
start := -1
for i, line := range lines {
if m := tableRe.FindStringSubmatch(line); m != nil && m[1] == table {
start = i
break
}
}
if start < 0 {
return nil, fmt.Errorf("the manifest holds no table [%s]", table)
}
end := len(lines)
for i := start + 1; i < len(lines); i++ {
if tableRe.MatchString(lines[i]) {
end = i
break
}
}
keys, at := tableKeys(lines, start+1, end)
if slices.Contains(keys, key) {
return nil, fmt.Errorf("the table [%s] already holds the key %s", table, key)
}
entry := renderEntry(key, value)
insert := insertionPoint(keys, at, key, lines, start, end)
out := make([]string, 0, len(lines)+1)
out = append(out, lines[:insert]...)
out = append(out, entry)
out = append(out, lines[insert:]...)
return []byte(strings.Join(out, "\n")), nil
}
// RemoveEntry drops a key from a table, leaving everything around it alone.
// Together with AddEntry it moves an entry from the live half of a section to
// the retired one, which is the only way a name ever leaves the live half.
func RemoveEntry(source []byte, table, key string) ([]byte, error) {
lines := strings.Split(string(source), "\n")
start := -1
for i, line := range lines {
if m := tableRe.FindStringSubmatch(line); m != nil && m[1] == table {
start = i
break
}
}
if start < 0 {
return nil, fmt.Errorf("the manifest holds no table [%s]", table)
}
end := len(lines)
for i := start + 1; i < len(lines); i++ {
if tableRe.MatchString(lines[i]) {
end = i
break
}
}
keys, at := tableKeys(lines, start+1, end)
for i, existing := range keys {
if existing != key {
continue
}
out := make([]string, 0, len(lines)-1)
out = append(out, lines[:at[i]]...)
out = append(out, lines[at[i]+1:]...)
return []byte(strings.Join(out, "\n")), nil
}
return nil, fmt.Errorf("the table [%s] holds no key %s", table, key)
}
// tableKeys collects the keys of a table together with the line each sits on.
func tableKeys(lines []string, from, to int) (keys []string, at []int) {
for i := from; i < to; i++ {
m := keyRe.FindStringSubmatch(lines[i])
if m == nil {
continue
}
keys = append(keys, strings.Trim(m[1], `"`))
at = append(at, i)
}
return keys, at
}
// insertionPoint picks the line the entry goes before.
func insertionPoint(keys []string, at []int, key string, lines []string, start, end int) int {
if len(keys) == 0 {
// An empty table owns the comments standing right under its header
// and nothing further: a comment block separated by a blank line
// belongs to the table header below it, not to this one. Walking to
// the end of the section instead would file the entry under the wrong
// explanation.
i := start + 1
for i < end && strings.HasPrefix(strings.TrimSpace(lines[i]), "#") {
i++
}
return i
}
if sort.StringsAreSorted(keys) {
for i, existing := range keys {
if key < existing {
return at[i]
}
}
}
// The entry goes after the last key, and a key is not always one line: an
// array written down a column ends where its bracket closes. Appending
// after the first line of it would land the entry inside the array.
return valueEnd(lines, at[len(at)-1], end) + 1
}
// valueEnd returns the last line the value of a key occupies.
func valueEnd(lines []string, at, to int) int {
depth := 0
for n := at; n < to; n++ {
code, _ := splitComment(lines[n])
if n == at {
if i := strings.Index(code, "="); i >= 0 {
code = code[i+1:]
}
}
_, next, closeAt := scanCode(code, depth)
if closeAt >= 0 {
return n
}
if depth = next; depth <= 0 {
return n
}
}
return at
}
// renderEntry writes one key-value line, quoting the key when it is not bare.
func renderEntry(key, value string) string {
if !bareRe.MatchString(key) {
key = Quote(key)
}
return key + " = " + Quote(value)
}
// Quote writes a string the way TOML reads it back. Every value the tool puts
// into a manifest goes through here: a Windows path is the ordinary case where
// a backslash left alone makes the tool unable to read the file it just wrote.
func Quote(s string) string {
return `"` + escape(s) + `"`
}
func escape(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
return strings.ReplaceAll(s, `"`, `\"`)
}
-130
View File
@@ -1,130 +0,0 @@
package manifest_test
import (
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/manifest"
)
func TestAddEntryKeepsComments(t *testing.T) {
source := `# The suite manifest.
[language]
version = 1
# ─── Topics ───
#
# A topic is a set of rules about one focus of development.
[topics.live]
config = "configuration"
time = "time"
[topics.retired]
# Empty. Retired names land here together with a reason and a date.
`
got, err := manifest.AddEntry([]byte(source), "topics.live", "logging", "logging: levels, structure")
if err != nil {
t.Fatal(err)
}
out := string(got)
for _, want := range []string{
"# ─── Topics ───",
"# A topic is a set of rules about one focus of development.",
"# Empty. Retired names land here together with a reason and a date.",
`logging = "logging: levels, structure"`,
} {
if !strings.Contains(out, want) {
t.Errorf("the result lost %q:\n%s", want, out)
}
}
}
// A table whose keys are already sorted keeps its order; one ordered by hand
// gets the entry appended, so that a grouping by directory survives.
func TestAddEntryRespectsExistingOrder(t *testing.T) {
sorted := `[topics.live]
config = "c"
time = "t"
`
got, err := manifest.AddEntry([]byte(sorted), "topics.live", "logging", "l")
if err != nil {
t.Fatal(err)
}
wantSorted := "[topics.live]\nconfig = \"c\"\nlogging = \"l\"\ntime = \"t\"\n"
if string(got) != wantSorted {
t.Errorf("a sorted table was not kept sorted:\n%s", got)
}
grouped := `[prefixes.live]
TIME = "conventions/arch/time.md"
CONF = "conventions/arch/config.md"
GTIM = "conventions/lang/go/time.md"
`
got, err = manifest.AddEntry([]byte(grouped), "prefixes.live", "GCFG", "conventions/lang/go/config.md")
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(strings.TrimRight(string(got), "\n"), `GCFG = "conventions/lang/go/config.md"`) {
t.Errorf("a hand-ordered table did not get the entry appended:\n%s", got)
}
}
func TestAddEntryIntoEmptyTable(t *testing.T) {
source := `[topics.live]
# Nothing yet.
[topics.retired]
`
got, err := manifest.AddEntry([]byte(source), "topics.live", "time", "time")
if err != nil {
t.Fatal(err)
}
want := "[topics.live]\n# Nothing yet.\ntime = \"time\"\n\n[topics.retired]\n"
if string(got) != want {
t.Errorf("insertion into an empty table went wrong:\n%q", got)
}
}
// A comment block separated from an empty table by a blank line explains the
// table header standing below it, not the one above. Filing an entry after such
// a block puts it under the wrong explanation.
func TestAddEntryIntoEmptyTableStopsBeforeTheNextComment(t *testing.T) {
source := `[topics.live]
# Retired names land here together with a reason and a date.
[topics.retired]
`
got, err := manifest.AddEntry([]byte(source), "topics.live", "time", "time")
if err != nil {
t.Fatal(err)
}
want := "[topics.live]\ntime = \"time\"\n\n# Retired names land here together with a reason and a date.\n\n[topics.retired]\n"
if string(got) != want {
t.Errorf("the entry was filed under the wrong comment:\n%q", got)
}
}
func TestAddEntryRejectsDuplicateAndMissingTable(t *testing.T) {
source := "[topics.live]\ntime = \"t\"\n"
if _, err := manifest.AddEntry([]byte(source), "topics.live", "time", "t"); err == nil {
t.Error("a duplicate key was accepted")
}
if _, err := manifest.AddEntry([]byte(source), "prefixes.live", "TIME", "x.md"); err == nil {
t.Error("a missing table was accepted")
}
}
func TestAddEntryQuotesWhatIsNotBare(t *testing.T) {
source := "[topics.live]\n"
got, err := manifest.AddEntry([]byte(source), "topics.live", "web ui", `a "quoted" thing`)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got), `"web ui" = "a \"quoted\" thing"`) {
t.Errorf("key or value was not escaped:\n%s", got)
}
}
-340
View File
@@ -1,340 +0,0 @@
package manifest
import (
"fmt"
"regexp"
"slices"
"strings"
)
// A subscription is an array rather than a key-value pair, and the project
// manifest is edited as text for the same reason the suite one is: what the
// author wrote around the data — which component is what, why a topic is taken
// — has to survive the tool touching the file.
//
// So the array is read as the manifest wrote it, not as a regular expression
// over the raw lines: a # inside a string does not open a comment, a bracket
// inside a comment does not close an array, and a name inside a comment is not
// a subscription. Getting any of the three wrong turns a comment into data, and
// there is no way back from that.
//
// The shape survives too. An array written on one line stays on one line, one
// written down a column stays a column, and the comments keep their places: a
// comment block above a value belongs to that value — the same rule that
// governs a comment above a table — so sorting carries it along, while a
// comment trailing on the same line stays on its line.
var arrayKeyRe = regexp.MustCompile(`^(\s*)("[^"]+"|[A-Za-z0-9_-]+)\s*=\s*\[`)
// element is one value of an array together with what was written around it.
type element struct {
value string
above []string
after string
}
// array is an array of the manifest, parsed.
type array struct {
indent string
key string
// column says the array was written down a column rather than on one line.
column bool
elems []element
// opening is a comment trailing the opening bracket.
opening string
// dangling holds comment lines standing after the last value.
dangling []string
// tail is whatever follows the closing bracket.
tail string
}
// AddToList adds a value to the array under key in the given table. A table
// that has no such key gets one holding the single value.
func AddToList(source []byte, table, key, value string) ([]byte, error) {
lines := strings.Split(string(source), "\n")
start, end, ok := tableBounds(lines, table)
if !ok {
return nil, fmt.Errorf("the manifest holds no table [%s]", table)
}
from, found := arrayKeyLine(lines, start+1, end, key)
if !found {
keys, at := tableKeys(lines, start+1, end)
insert := insertionPoint(keys, at, key, lines, start, end)
entry := fmt.Sprintf(`%s = ["%s"]`, key, escape(value))
out := make([]string, 0, len(lines)+1)
out = append(out, lines[:insert]...)
out = append(out, entry)
out = append(out, lines[insert:]...)
return []byte(strings.Join(out, "\n")), nil
}
a, last, ok := readArray(lines, from, end)
if !ok {
return nil, fmt.Errorf("the array %s in [%s] is not closed by a bracket", key, table)
}
if slices.ContainsFunc(a.elems, func(e element) bool { return e.value == value }) {
return nil, fmt.Errorf("%s already holds %q", key, value)
}
sorted := slices.IsSortedFunc(a.elems, byValue)
a.elems = append(a.elems, element{value: value})
if sorted {
slices.SortStableFunc(a.elems, byValue)
}
return splice(lines, from, last, a.render()), nil
}
// RemoveFromList drops a value from the array under key. It is the other half
// of AddToList: unsubscribing is not a command yet, and a pair of edits where
// only one direction is written is a pair where the untried direction is wrong.
func RemoveFromList(source []byte, table, key, value string) ([]byte, error) {
lines := strings.Split(string(source), "\n")
start, end, ok := tableBounds(lines, table)
if !ok {
return nil, fmt.Errorf("the manifest holds no table [%s]", table)
}
from, found := arrayKeyLine(lines, start+1, end, key)
if !found {
return nil, fmt.Errorf("the table [%s] holds no key %s", table, key)
}
a, last, ok := readArray(lines, from, end)
if !ok {
return nil, fmt.Errorf("the array %s in [%s] is not closed by a bracket", key, table)
}
i := slices.IndexFunc(a.elems, func(e element) bool { return e.value == value })
if i < 0 {
return nil, fmt.Errorf("%s does not hold %q", key, value)
}
// The comment above a value went with it and goes away with it; a comment
// left hanging over the next value would say the wrong thing about it.
a.elems = slices.Delete(a.elems, i, i+1)
return splice(lines, from, last, a.render()), nil
}
func byValue(a, b element) int { return strings.Compare(a.value, b.value) }
// AddTable appends a table to the end of the manifest. A new component is a new
// table, and it goes last because the order of components is the author's:
// there is nothing to sort them by that would mean anything.
func AddTable(source []byte, table string, entries [][2]string) ([]byte, error) {
lines := strings.Split(string(source), "\n")
if _, _, ok := tableBounds(lines, table); ok {
return nil, fmt.Errorf("the manifest already holds the table [%s]", table)
}
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
lines = lines[:len(lines)-1]
}
block := []string{"", "[" + table + "]"}
for _, e := range entries {
block = append(block, e[0]+" = "+e[1])
}
block = append(block, "")
return []byte(strings.Join(append(lines, block...), "\n")), nil
}
// tableBounds finds the lines a table spans: its header and the line the next
// table starts on.
func tableBounds(lines []string, table string) (start, end int, ok bool) {
start = -1
for i, line := range lines {
if m := tableRe.FindStringSubmatch(line); m != nil && m[1] == table {
start = i
break
}
}
if start < 0 {
return 0, 0, false
}
end = len(lines)
for i := start + 1; i < len(lines); i++ {
if tableRe.MatchString(lines[i]) {
end = i
break
}
}
return start, end, true
}
// arrayKeyLine finds the line an array opens on.
func arrayKeyLine(lines []string, from, to int, key string) (int, bool) {
for i := from; i < to; i++ {
code, _ := splitComment(lines[i])
m := arrayKeyRe.FindStringSubmatch(code)
if m != nil && strings.Trim(m[2], `"`) == key {
return i, true
}
}
return 0, false
}
// readArray parses the array opening on line from and returns the line it
// closes on.
func readArray(lines []string, from, to int) (a array, last int, ok bool) {
code, comment := splitComment(lines[from])
m := arrayKeyRe.FindStringSubmatch(code)
if m == nil {
return a, 0, false
}
a.indent, a.key = m[1], strings.Trim(m[2], `"`)
open := strings.Index(code, "[")
values, depth, closeAt := scanCode(code[open:], 0)
for _, v := range values {
a.elems = append(a.elems, element{value: v})
}
if closeAt >= 0 {
a.tail = joinTail(code[open+closeAt:], comment)
return a, from, true
}
a.column = true
a.opening = comment
var pending []string
for n := from + 1; n < to; n++ {
code, comment := splitComment(lines[n])
values, next, closeAt := scanCode(code, depth)
depth = next
if len(values) == 0 && strings.TrimSpace(code) == "" && closeAt < 0 {
if comment != "" {
pending = append(pending, lines[n])
}
continue
}
for i, v := range values {
e := element{value: v}
if i == 0 {
e.above, pending = pending, nil
}
if i == len(values)-1 && closeAt < 0 {
e.after = comment
}
a.elems = append(a.elems, e)
}
if closeAt >= 0 {
a.dangling = pending
a.tail = joinTail(code[closeAt:], comment)
return a, n, true
}
}
return a, 0, false
}
// render writes the array back in the shape it had.
func (a array) render() []string {
quoted := make([]string, len(a.elems))
for i, e := range a.elems {
quoted[i] = `"` + escape(e.value) + `"`
}
if !a.column {
line := fmt.Sprintf("%s%s = [%s]", a.indent, a.key, strings.Join(quoted, ", "))
return []string{appendTail(line, a.tail)}
}
out := []string{appendTail(a.indent+a.key+" = [", a.opening)}
for i, e := range a.elems {
out = append(out, e.above...)
out = append(out, appendTail(a.indent+" "+quoted[i]+",", e.after))
}
out = append(out, a.dangling...)
return append(out, appendTail(a.indent+"]", a.tail))
}
func appendTail(line, tail string) string {
if tail == "" {
return line
}
return line + " " + tail
}
func joinTail(rest, comment string) string {
return strings.TrimSpace(strings.TrimSpace(rest) + " " + comment)
}
// splitComment cuts a line into its code and its comment. A # inside a string
// opens no comment, which is the whole reason this is not a call to strings.Cut.
func splitComment(line string) (code, comment string) {
quote := byte(0)
for i := 0; i < len(line); i++ {
c := line[i]
if quote != 0 {
if quote == '"' && c == '\\' {
i++
continue
}
if c == quote {
quote = 0
}
continue
}
switch c {
case '"', '\'':
quote = c
case '#':
return line[:i], line[i:]
}
}
return line, ""
}
// scanCode walks the code of a line, collecting the strings in it and following
// the bracket depth. closeAt is the offset just past the bracket that brought
// the depth back to zero, or -1 while the array is still open.
func scanCode(code string, depth int) (values []string, depthOut, closeAt int) {
closeAt = -1
for i := 0; i < len(code); i++ {
switch c := code[i]; c {
case '"', '\'':
var b strings.Builder
j := i + 1
for j < len(code) {
if c == '"' && code[j] == '\\' && j+1 < len(code) {
b.WriteString(code[j : j+2])
j += 2
continue
}
if code[j] == c {
break
}
b.WriteByte(code[j])
j++
}
text := b.String()
if c == '"' {
text = unescape(text)
}
values = append(values, text)
i = j
case '[':
depth++
case ']':
depth--
if depth == 0 && closeAt < 0 {
closeAt = i + 1
}
}
}
return values, depth, closeAt
}
// splice replaces lines from..to inclusive with the given block.
func splice(lines []string, from, to int, block []string) []byte {
out := make([]string, 0, len(lines)+len(block))
out = append(out, lines[:from]...)
out = append(out, block...)
if to < len(lines) {
out = append(out, lines[to+1:]...)
}
return []byte(strings.Join(out, "\n"))
}
func unescape(s string) string {
s = strings.ReplaceAll(s, `\"`, `"`)
return strings.ReplaceAll(s, `\\`, `\`)
}
-194
View File
@@ -1,194 +0,0 @@
package manifest_test
import (
"slices"
"strings"
"testing"
"github.com/BurntSushi/toml"
"git.vakhrushev.me/av/convy/internal/manifest"
)
const projectSource = `# What this repository takes.
source = "../conventions"
# A component is a region where every chosen layer holds at once.
[components.backend]
dir = "backend/docs/conventions"
lang = ["go"]
topics = ["errors", "time"]
[components.web]
dir = "web/docs/conventions"
topics = [
"client-logging",
]
`
func TestAddToListKeepsTheShapeOfTheArray(t *testing.T) {
out, err := manifest.AddToList([]byte(projectSource), "components.backend", "topics", "logging")
if err != nil {
t.Fatal(err)
}
body := string(out)
if !strings.Contains(body, `topics = ["errors", "logging", "time"]`) {
t.Errorf("a sorted one-line array did not stay sorted and on one line:\n%s", body)
}
if !strings.Contains(body, "# A component is a region where every chosen layer holds at once.") {
t.Errorf("the comment did not survive the edit:\n%s", body)
}
out, err = manifest.AddToList(out, "components.web", "topics", "auth")
if err != nil {
t.Fatal(err)
}
want := "topics = [\n \"auth\",\n \"client-logging\",\n]"
if !strings.Contains(string(out), want) {
t.Errorf("an array written down a column did not stay a column:\n%s", out)
}
}
func TestAddToListCreatesTheKeyItDoesNotFind(t *testing.T) {
out, err := manifest.AddToList([]byte(projectSource), "components.web", "stack", "express")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(out), `stack = ["express"]`) {
t.Errorf("the missing key was not created:\n%s", out)
}
}
func TestAddToListRefusesWhatIsThereAlready(t *testing.T) {
_, err := manifest.AddToList([]byte(projectSource), "components.backend", "topics", "time")
if err == nil || !strings.Contains(err.Error(), "already holds") {
t.Errorf("a repeated subscription went through: %v", err)
}
if _, err := manifest.AddToList([]byte(projectSource), "components.mobile", "topics", "time"); err == nil {
t.Errorf("a table that is not there took an entry")
}
}
func TestRemoveFromList(t *testing.T) {
out, err := manifest.RemoveFromList([]byte(projectSource), "components.backend", "topics", "errors")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(out), `topics = ["time"]`) {
t.Errorf("the value was not removed:\n%s", out)
}
if _, err := manifest.RemoveFromList([]byte(projectSource), "components.backend", "topics", "logging"); err == nil {
t.Errorf("removing what is not there went through")
}
}
func TestAddTableGoesLast(t *testing.T) {
out, err := manifest.AddTable([]byte(projectSource), "components.mobile",
[][2]string{{"dir", `"mobile/docs/conventions"`}, {"topics", "[]"}})
if err != nil {
t.Fatal(err)
}
body := string(out)
if !strings.Contains(body, "[components.mobile]\ndir = \"mobile/docs/conventions\"\ntopics = []") {
t.Errorf("the table was not written:\n%s", body)
}
if strings.Index(body, "[components.mobile]") < strings.Index(body, "[components.web]") {
t.Errorf("the new table did not go last:\n%s", body)
}
if _, err := manifest.AddTable(out, "components.mobile", nil); err == nil {
t.Errorf("a second table of the same name went through")
}
}
// The array is read the way the manifest wrote it. A # inside a string opens no
// comment, a bracket inside a comment closes no array, and a name inside a
// comment is not a subscription — turning any of the three into data is the one
// mistake there is no way back from.
func TestAddToListReadsCommentsAsComments(t *testing.T) {
cases := []struct {
name string
src string
add string
values []string
remains string
}{{
name: "a name quoted inside a comment",
src: "[c.app]\ntopics = [\n \"errors\",\n # \"config\" is not taken yet\n]\n",
add: "time",
values: []string{"errors", "time"},
remains: `# "config" is not taken yet`,
}, {
name: "a bracket inside a comment",
src: "[c.app]\ntopics = [\n \"errors\", # [enough for now]\n \"db-schema\",\n]\n",
add: "config",
values: []string{"errors", "db-schema", "config"},
remains: `"errors", # [enough for now]`,
}, {
name: "a comment trailing a one-line array",
src: "[c.app]\ntopics = [\"git\"] # only git for now\n",
add: "time",
values: []string{"git", "time"},
remains: `topics = ["git", "time"] # only git for now`,
}, {
name: "a hash inside a value",
src: "[c.app]\ntopics = [\"a#b\"]\n",
add: "time",
values: []string{"a#b", "time"},
remains: `topics = ["a#b", "time"]`,
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := manifest.AddToList([]byte(tc.src), "c.app", "topics", tc.add)
if err != nil {
t.Fatal(err)
}
got := decodeTopics(t, out)
if !slices.Equal(got, tc.values) {
t.Errorf("the array holds %q, expected %q:\n%s", got, tc.values, out)
}
if !strings.Contains(string(out), tc.remains) {
t.Errorf("what the author wrote is gone — no %q:\n%s", tc.remains, out)
}
})
}
}
// decodeTopics reads the array back with the parser the tool itself uses.
func decodeTopics(t *testing.T, source []byte) []string {
t.Helper()
var got struct {
C map[string]struct{ Topics []string } `toml:"c"`
}
if _, err := toml.Decode(string(source), &got); err != nil {
t.Fatalf("the manifest the tool wrote does not parse: %v\n%s", err, source)
}
return got.C["app"].Topics
}
// A key is not always one line. Appending after the first line of an array
// written down a column would land the new entry inside it.
func TestAddToListAppendsPastAMultilineNeighbour(t *testing.T) {
src := "[c.app]\ndir = \"docs\"\nstack = [\n \"sqlite\",\n \"postgres\",\n]\n"
out, err := manifest.AddToList([]byte(src), "c.app", "topics", "errors")
if err != nil {
t.Fatal(err)
}
body := string(out)
if !strings.Contains(body, " \"postgres\",\n]\ntopics = [\"errors\"]") {
t.Errorf("the new key did not land past the array:\n%s", body)
}
}
// Whatever the tool writes into a manifest, it has to be able to read back.
func TestQuoteSurvivesTheRoundTrip(t *testing.T) {
for _, value := range []string{`docs\conventions`, `a "quoted" name`, `both\ "kinds"`} {
out, err := manifest.AddToList([]byte("[c.app]\ntopics = []\n"), "c.app", "topics", value)
if err != nil {
t.Fatal(err)
}
if list := decodeTopics(t, out); len(list) != 1 || list[0] != value {
t.Errorf("%q came back as %q", value, list)
}
}
}
+68 -10
View File
@@ -1,12 +1,24 @@
// Package manifest reads suite.toml, the manifest of a conventions suite.
// Package manifest reads and writes the two manifests of the model.
//
// The manifest declares three things: the language the suite's rules are
// The suite manifest declares three things: the language the suite's rules are
// written in, its live and retired topics, and its live and retired rule
// prefixes together with the paths of their files. The tool knows no topic and
// no prefix in advance — that whole list arrives from here.
//
// Both manifests are data the tool edits, so both are decoded into structs and
// written back out of them. They carry no comments: a file a machine rewrites
// cannot keep a comment through the round trip, and pretending otherwise costs
// the comment on a day nobody is watching. What a topic is for is said in the
// documents next to the manifest, which no command touches.
//
// Because a write goes out of the structs, a key the tool does not know would
// disappear on the next edit. So it does not write at all while one is there:
// a refusal naming the key is the only outcome that neither loses it nor hides
// it.
package manifest
import (
"bytes"
"errors"
"fmt"
"os"
@@ -38,25 +50,44 @@ const DefaultLanguageCode = "ru"
// changing — the vocabulary is picked by version and code either way.
type Language struct {
Version int `toml:"version"`
Lang string `toml:"lang"`
Source string `toml:"source"`
Description string `toml:"description"`
Reading string `toml:"reading"`
Lang string `toml:"lang,omitempty"`
Source string `toml:"source,omitempty"`
Description string `toml:"description,omitempty"`
Reading string `toml:"reading,omitempty"`
}
// Section is a part of the manifest split into a live and a retired half.
// Retired entries are kept rather than deleted: a topic name and a rule prefix
// live on in foreign repositories, and neither may ever be reused.
type Section struct {
Live map[string]string `toml:"live"`
Retired map[string]string `toml:"retired"`
Live map[string]string `toml:"live,omitempty"`
Retired map[string]string `toml:"retired,omitempty"`
}
// Add puts an entry into the live half, making the map if there is none.
func (s *Section) Add(key, value string) {
if s.Live == nil {
s.Live = make(map[string]string)
}
s.Live[key] = value
}
// Retire moves an entry out of the live half into the retired one. A name is
// never deleted and never reissued: it lives on in foreign repositories, and a
// name handed out twice starts pointing at something else there.
func (s *Section) Retire(key, note string) {
delete(s.Live, key)
if s.Retired == nil {
s.Retired = make(map[string]string)
}
s.Retired[key] = note
}
// Manifest is a parsed suite.toml.
type Manifest struct {
Language Language `toml:"language"`
Topics Section `toml:"topics"`
Prefixes Section `toml:"prefixes"`
Topics Section `toml:"topics,omitempty"`
Prefixes Section `toml:"prefixes,omitempty"`
// Path is where the manifest was read from.
Path string `toml:"-"`
@@ -91,6 +122,33 @@ func Load(root string) (*Manifest, error) {
return &m, nil
}
// Save writes the manifest back to where it was read from.
func (m *Manifest) Save() error {
return save(m.Path, m, m.Undecoded)
}
// save encodes a manifest and puts it in place.
func save(path string, value any, undecoded []string) error {
if len(undecoded) > 0 {
return fmt.Errorf("%s holds %s the tool does not know (%s); a write goes out of what the tool understands, so the key would be dropped — fix the spelling first",
path, plural(len(undecoded), "key"), strings.Join(undecoded, ", "))
}
var b bytes.Buffer
enc := toml.NewEncoder(&b)
enc.Indent = ""
if err := enc.Encode(value); err != nil {
return fmt.Errorf("encoding %s: %w", path, err)
}
return os.WriteFile(path, b.Bytes(), 0o644)
}
func plural(n int, noun string) string {
if n == 1 {
return fmt.Sprintf("%d %s", n, noun)
}
return fmt.Sprintf("%d %ss", n, noun)
}
// Find walks up from start looking for a directory that holds a suite
// manifest, so that `convy suite check` works from any subdirectory of a suite.
func Find(start string) (string, error) {
+168
View File
@@ -0,0 +1,168 @@
package manifest_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/manifest"
)
func write(t *testing.T, dir, name, body string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return path
}
// A manifest is data, and a command that changes it rewrites it whole. The
// second write of the same content has to come out the same, or every command
// would leave a diff of its own on top of the one it meant.
func TestSaveIsIdempotent(t *testing.T) {
dir := t.TempDir()
write(t, dir, manifest.Name, `[language]
version = 1
lang = "ru"
[topics.live]
time = "время"
[prefixes.live]
TIME = "conventions/time.md"
`)
m, err := manifest.Load(dir)
if err != nil {
t.Fatal(err)
}
if err := m.Save(); err != nil {
t.Fatal(err)
}
once, err := os.ReadFile(m.Path)
if err != nil {
t.Fatal(err)
}
again, err := manifest.Load(dir)
if err != nil {
t.Fatalf("the manifest the tool wrote does not load: %v\n%s", err, once)
}
if err := again.Save(); err != nil {
t.Fatal(err)
}
twice, err := os.ReadFile(m.Path)
if err != nil {
t.Fatal(err)
}
if string(once) != string(twice) {
t.Errorf("the second write differs from the first:\n%s\n---\n%s", once, twice)
}
}
// A write goes out of the structs, so a key the tool does not know would be
// dropped. It refuses instead: that neither loses the key nor hides it.
func TestSaveRefusesWhileAKeyIsUnknown(t *testing.T) {
dir := t.TempDir()
write(t, dir, manifest.Name, `[language]
version = 1
descriptoin = "LANGUAGE.md"
`)
m, err := manifest.Load(dir)
if err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(m.Path)
err = m.Save()
if err == nil {
t.Fatal("the manifest was rewritten over a key the tool does not know")
}
if !strings.Contains(err.Error(), "descriptoin") {
t.Errorf("the refusal does not name the key: %s", err)
}
after, _ := os.ReadFile(m.Path)
if string(before) != string(after) {
t.Errorf("the file was touched anyway:\n%s", after)
}
}
// A name never leaves the manifest: it lives on in foreign repositories, and
// one handed out twice starts pointing at something else there.
func TestRetireMovesRatherThanDeletes(t *testing.T) {
dir := t.TempDir()
write(t, dir, manifest.Name, `[language]
version = 1
[topics.live]
time = "время"
logging = "логирование"
`)
m, err := manifest.Load(dir)
if err != nil {
t.Fatal(err)
}
m.Topics.Retire("logging", "2026-07-28: свёрнута в errors")
if err := m.Save(); err != nil {
t.Fatal(err)
}
back, err := manifest.Load(dir)
if err != nil {
t.Fatal(err)
}
if back.TopicLive("logging") {
t.Errorf("the topic stayed live")
}
if !back.TopicRetired("logging") {
t.Errorf("the topic is neither live nor retired: the name is loose")
}
if !back.TopicLive("time") {
t.Errorf("the other topic went with it")
}
}
func TestProjectRoundTrip(t *testing.T) {
dir := t.TempDir()
p := &manifest.Project{
Source: "../dev-conventions#v2",
Components: map[string]manifest.Component{
"backend": {Dir: `docs\conventions`, Lang: []string{"go"}, Topics: []string{"time"}},
"web": {Dir: "web/docs", Topics: []string{}},
},
Path: filepath.Join(dir, manifest.ProjectName),
Root: dir,
}
if err := p.Save(); err != nil {
t.Fatal(err)
}
back, err := manifest.LoadProject(dir)
if err != nil {
t.Fatalf("the manifest the tool wrote does not load: %v", err)
}
if back.Source != p.Source {
t.Errorf("source came back as %q", back.Source)
}
// A backslash is the ordinary way a written value fails to read back.
if got := back.Components["backend"].Dir; got != `docs\conventions` {
t.Errorf("the directory came back as %q", got)
}
if len(back.Components["web"].Lang) != 0 {
t.Errorf("an empty axis was written and read back as something")
}
back.Subscribe("web", "logging")
back.Subscribe("web", "errors")
if got := back.Components["web"].Topics; strings.Join(got, ",") != "errors,logging" {
t.Errorf("the subscription is not kept in order: %v", got)
}
back.Unsubscribe("web", "errors")
if got := back.Components["web"].Topics; strings.Join(got, ",") != "logging" {
t.Errorf("unsubscribing left %v", got)
}
}
+24 -2
View File
@@ -26,8 +26,8 @@ const ProjectName = ".conventions.toml"
// written in one of them, which is what a component exists to separate.
type Component struct {
Dir string `toml:"dir"`
Lang []string `toml:"lang"`
Stack []string `toml:"stack"`
Lang []string `toml:"lang,omitempty"`
Stack []string `toml:"stack,omitempty"`
Topics []string `toml:"topics"`
}
@@ -71,6 +71,28 @@ func LoadProject(root string) (*Project, error) {
return &p, nil
}
// Save writes the project manifest back to where it was read from.
func (p *Project) Save() error {
return save(p.Path, p, p.Undecoded)
}
// Subscribe adds a topic to a component, keeping the list sorted so that the
// file does not churn on the order things were added in.
func (p *Project) Subscribe(name, topic string) {
c := p.Components[name]
c.Topics = append(c.Topics, topic)
sort.Strings(c.Topics)
p.Components[name] = c
}
// Unsubscribe drops a topic from a component. Nothing is kept behind: a
// subscription is a choice of the project, not a name anyone else may reuse.
func (p *Project) Unsubscribe(name, topic string) {
c := p.Components[name]
c.Topics = slices.DeleteFunc(c.Topics, func(t string) bool { return t == topic })
p.Components[name] = c
}
// FindProject walks up from start looking for a project manifest, so that a
// command works from any subdirectory of a repository.
func FindProject(start string) (string, error) {