// 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"` 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 } // 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 }