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, ", ") }