- документ самоуправления объявляется ключом governance, а не угадывается по «он один и без ключей оси»: конвенция, потерявшая topic, была от него неотличима и тихо теряла все проверки об отъезде к потребителю - проверка путей канона больше не ловит README.md и READING.md — эти два имени значат что-то и на стороне потребителя - lang.Recognize требует совпадения и слов, и номера версии; директории компонентов сверяются на вложенность, а не только на равенство - у обеих проверок появился --json, а convy sync называет ссылки на темы, которых компонент не взял
268 lines
8.9 KiB
Go
268 lines
8.9 KiB
Go
// Package manifest reads and writes the two manifests of the model.
|
|
//
|
|
// 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"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
)
|
|
|
|
// Name is the name of a suite manifest. Which of the two manifests lies next
|
|
// to you tells you where you are, and both start with a dot for the same
|
|
// reason: a manifest is data the tool writes, not a document of the repository,
|
|
// and it sits with the rest of the service files rather than among the
|
|
// conventions themselves.
|
|
const Name = ".conventions-suite.toml"
|
|
|
|
// DefaultLanguageCode is the suite's natural language when the manifest says
|
|
// nothing about it. The key is optional on purpose: the vocabulary lives in the
|
|
// binary, and making every suite declare what is already implied buys nothing.
|
|
const DefaultLanguageCode = "ru"
|
|
|
|
// Language is the [language] section: the version of the conventions language,
|
|
// the natural language its words are written in, and the two documents about
|
|
// it. The full description stays with the author of the suite, the short one
|
|
// travels into the copy.
|
|
//
|
|
// Source is where those two documents live. Empty means the suite itself, which
|
|
// is where they lie while the specification of the language has no repository
|
|
// of its own; once it moves out, the same key names it without anything else
|
|
// changing — the vocabulary is picked by version and code either way.
|
|
type Language struct {
|
|
Version int `toml:"version"`
|
|
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,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 manifest.
|
|
type Manifest struct {
|
|
Language Language `toml:"language"`
|
|
// Governance is the path of the document the suite governs itself by: the
|
|
// one written in the conventions language yet belonging to no topic, so
|
|
// that nobody can subscribe to it and it travels nowhere.
|
|
//
|
|
// It is declared rather than guessed. A convention that lost its topic key
|
|
// looks exactly like it, and the loss is the expensive one — the file keeps
|
|
// every check of form while quietly dropping every check about travelling
|
|
// to a consumer.
|
|
Governance string `toml:"governance,omitempty"`
|
|
Topics Section `toml:"topics,omitempty"`
|
|
Prefixes Section `toml:"prefixes,omitempty"`
|
|
|
|
// Path is where the manifest was read from.
|
|
Path string `toml:"-"`
|
|
// Undecoded lists keys the tool does not know. A typo in the manifest
|
|
// would otherwise pass in silence, and it costs a subscription or a
|
|
// whole file.
|
|
Undecoded []string `toml:"-"`
|
|
}
|
|
|
|
// Load reads the suite manifest from directory root.
|
|
func Load(root string) (*Manifest, error) {
|
|
path := filepath.Join(root, Name)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading the suite manifest: %w", err)
|
|
}
|
|
|
|
var m Manifest
|
|
meta, err := toml.Decode(string(data), &m)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing %s: %w", path, err)
|
|
}
|
|
m.Path = path
|
|
for _, key := range meta.Undecoded() {
|
|
m.Undecoded = append(m.Undecoded, key.String())
|
|
}
|
|
sort.Strings(m.Undecoded)
|
|
|
|
if m.Language.Lang == "" {
|
|
m.Language.Lang = DefaultLanguageCode
|
|
}
|
|
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) {
|
|
return findUp(start, Name)
|
|
}
|
|
|
|
// findUp walks up from start looking for a directory holding the named file.
|
|
func findUp(start, name string) (string, error) {
|
|
dir, err := filepath.Abs(start)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
|
|
return dir, nil
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
return "", ErrNotFound
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
// ErrNotFound means there is no suite manifest here or above.
|
|
var ErrNotFound = errors.New("suite manifest not found")
|
|
|
|
// LivePrefixes lists the live prefixes in an order stable between runs: the
|
|
// output of a check must not depend on map iteration.
|
|
func (m *Manifest) LivePrefixes() []string {
|
|
return sortedKeys(m.Prefixes.Live)
|
|
}
|
|
|
|
// LiveTopics lists the live topics in a stable order.
|
|
func (m *Manifest) LiveTopics() []string {
|
|
return sortedKeys(m.Topics.Live)
|
|
}
|
|
|
|
// PrefixOf returns the prefix declared for a file, if there is one. Paths are
|
|
// compared in slash form, the way the manifest writes them.
|
|
func (m *Manifest) PrefixOf(path string) (string, bool) {
|
|
want := filepath.ToSlash(path)
|
|
for prefix, declared := range m.Prefixes.Live {
|
|
if filepath.ToSlash(declared) == want {
|
|
return prefix, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// PathOf returns the path declared for a live prefix.
|
|
func (m *Manifest) PathOf(prefix string) (string, bool) {
|
|
path, ok := m.Prefixes.Live[prefix]
|
|
return path, ok
|
|
}
|
|
|
|
// Governs reports whether the path is the document the suite governs itself by.
|
|
func (m *Manifest) Governs(path string) bool {
|
|
return m.Governance != "" && filepath.ToSlash(m.Governance) == filepath.ToSlash(path)
|
|
}
|
|
|
|
// TopicLive reports whether the topic is declared among the live ones.
|
|
func (m *Manifest) TopicLive(topic string) bool {
|
|
_, ok := m.Topics.Live[topic]
|
|
return ok
|
|
}
|
|
|
|
// TopicRetired reports whether the topic is listed among the retired ones.
|
|
func (m *Manifest) TopicRetired(topic string) bool {
|
|
_, ok := m.Topics.Retired[topic]
|
|
return ok
|
|
}
|
|
|
|
// PrefixRetired reports whether the prefix is listed among the retired ones.
|
|
func (m *Manifest) PrefixRetired(prefix string) bool {
|
|
_, ok := m.Prefixes.Retired[prefix]
|
|
return ok
|
|
}
|
|
|
|
// ValidPrefix checks the shape of a prefix: four uppercase Latin letters. The
|
|
// letter X in first position is reserved for consuming repositories, and the
|
|
// suite never takes it.
|
|
func ValidPrefix(prefix string) error {
|
|
if len(prefix) != 4 {
|
|
return fmt.Errorf("prefix %q is not four letters", prefix)
|
|
}
|
|
for _, r := range prefix {
|
|
if r < 'A' || r > 'Z' {
|
|
return fmt.Errorf("prefix %q holds a character that is not an uppercase Latin letter", prefix)
|
|
}
|
|
}
|
|
if strings.HasPrefix(prefix, "X") {
|
|
return fmt.Errorf("prefix %q starts with X, a letter reserved for the local rules of consumers", prefix)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sortedKeys(m map[string]string) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|