Files
convy/internal/manifest/project.go
T
av 92bd1f463d манифесты стали данными, заведён convy sync
- убраны комментарии из suite.toml и .conventions.toml: файл, который
  машина переписывает, комментарий через круг не проносит; объяснения
  ушли в README рядом, который suite init теперь заводит
- удалена текстовая правка манифеста целиком — 520 строк ручного
  лексера TOML вместе со всем классом ошибок порчи данных
- запись идёт из структур энкодером; ключ, которого инструмент не
  знает, запись останавливает, а не теряется молча
- convy sync сверяет манифест и подводит под него раскладку файлов:
  чего не хватает — собирает, что осиротело — удаляет, копию с
  локальной частью не трогает никогда
2026-07-28 09:45:10 +03:00

141 lines
4.6 KiB
Go

package manifest
import (
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"github.com/BurntSushi/toml"
)
// ProjectName is the name of the manifest of a consuming repository. Which of
// the two manifests lies next to you tells you where you are, so the project
// one is never named like the suite one.
const ProjectName = ".conventions.toml"
// Component is one addressee of assembly inside a project: a region where every
// selected layer holds at once — one language, one set of tools, one kind of
// application (META-36).
//
// Lang and Stack are lists because a component may well take two stack layers
// at a time — sqlite and postgres in the schema topic hold together, being
// different tables of one service. Two languages never do: a line of code is
// written in one of them, which is what a component exists to separate.
type Component struct {
Dir string `toml:"dir"`
Lang []string `toml:"lang,omitempty"`
Stack []string `toml:"stack,omitempty"`
Topics []string `toml:"topics"`
}
// Project is a parsed .conventions.toml: where the copies are taken from and
// which components take what.
type Project struct {
// Source is the reference to the suite. How the tool reaches it — a path
// on disk, a git repository — is a matter of the reference itself.
Source string `toml:"source"`
Components map[string]Component `toml:"components"`
// Path is where the manifest was read from.
Path string `toml:"-"`
// Root is the directory the manifest lies in; every path in it is relative
// to that directory.
Root string `toml:"-"`
// Undecoded lists keys the tool does not know: a typo in a component name
// or in a key would otherwise cost a whole subscription in silence.
Undecoded []string `toml:"-"`
}
// LoadProject reads the project manifest from directory root.
func LoadProject(root string) (*Project, error) {
path := filepath.Join(root, ProjectName)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading the project manifest: %w", err)
}
var p Project
meta, err := toml.Decode(string(data), &p)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
p.Path = path
p.Root = root
for _, key := range meta.Undecoded() {
p.Undecoded = append(p.Undecoded, key.String())
}
sort.Strings(p.Undecoded)
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) {
return findUp(start, ProjectName)
}
// Names lists the components in an order stable between runs.
func (p *Project) Names() []string {
names := make([]string, 0, len(p.Components))
for name := range p.Components {
names = append(names, name)
}
sort.Strings(names)
return names
}
// Only picks the component a command works on. With a name given it is that
// one; without a name it is the single component of the project. A project of
// several components does not get one guessed for it — it gets the list.
func (p *Project) Only(name string) (string, Component, error) {
if name != "" {
c, ok := p.Components[name]
if !ok {
return "", Component{}, fmt.Errorf("the project declares no component %q; it declares: %s", name, joinNames(p.Names()))
}
return name, c, nil
}
switch len(p.Components) {
case 0:
return "", Component{}, fmt.Errorf("%s declares no component, and a copy is assembled for a component", p.Path)
case 1:
only := p.Names()[0]
return only, p.Components[only], nil
}
return "", Component{}, fmt.Errorf("the project holds several components, and the command names none: pass --for with one of %s", joinNames(p.Names()))
}
// Subscribed reports whether a component takes a topic.
func (c Component) Subscribed(topic string) bool {
return slices.Contains(c.Topics, topic)
}
func joinNames(names []string) string {
return strings.Join(names, ", ")
}