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