манифесты стали данными, заведён 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
+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)
}
}