// Package manifest reads suite.toml, the manifest of a conventions suite. // // The 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. package manifest import ( "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: suite.toml means a suite, .conventions.toml // means a project. const Name = "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 // and the two documents about it. The full description stays with the author of // the suite, the short one travels into the copy. type Language struct { Version int `toml:"version"` Lang string `toml:"lang"` Description string `toml:"description"` Reading string `toml:"reading"` } // 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"` } // Manifest is a parsed suite.toml. type Manifest struct { Language Language `toml:"language"` Topics Section `toml:"topics"` Prefixes Section `toml:"prefixes"` // 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 } // 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) { 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 }