Files
convy/internal/cli/project.go
T
av 600aba5ee3 закрыты известные остатки
- документ самоуправления объявляется ключом governance, а не угадывается
  по «он один и без ключей оси»: конвенция, потерявшая topic, была от него
  неотличима и тихо теряла все проверки об отъезде к потребителю
- проверка путей канона больше не ловит README.md и READING.md — эти два
  имени значат что-то и на стороне потребителя
- lang.Recognize требует совпадения и слов, и номера версии; директории
  компонентов сверяются на вложенность, а не только на равенство
- у обеих проверок появился --json, а convy sync называет ссылки на темы,
  которых компонент не взял
2026-07-28 10:16:27 +03:00

199 lines
6.4 KiB
Go

package cli
import (
"fmt"
"os"
"path/filepath"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/source"
"git.vakhrushev.me/av/convy/internal/suite"
)
// A project command works against two levels at once: the manifest lying in the
// repository and the suite it names. Reaching the suite costs a clone when the
// reference is a git one, so it is done once per command and released at the
// end — nothing of the suite is left lying about in the project.
// opened is a project together with the levels above it.
type opened struct {
Root string
Manifest *manifest.Project
Suite *suite.Suite
// LangRoot is where the documents about the language lie. It is the root
// of the suite while the language has no repository of its own.
LangRoot string
trees []*source.Tree
}
// Close releases whatever the opening fetched.
func (o *opened) Close() {
for _, t := range o.trees {
t.Close()
}
}
// openProject finds the project, resolves its source and loads the suite.
func openProject(env Env, root string) (*opened, ExitCode) {
dir, code := projectRoot(env, root)
if code != OK {
return nil, code
}
m, code := loadProject(env, dir)
if code != OK {
return nil, code
}
if m.Source == "" {
fmt.Fprintf(env.Err, "%s names no source: a copy comes from a suite, and the manifest is where the suite is named\n", m.Path)
return nil, Usage
}
ref, err := source.Parse(m.Source)
if err != nil {
fmt.Fprintln(env.Err, err)
return nil, Usage
}
tree, err := source.Open(ref, dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return nil, Failed
}
o := &opened{Root: dir, Manifest: m, LangRoot: tree.Dir(), trees: []*source.Tree{tree}}
o.Suite, err = suite.Load(tree.Dir())
if err != nil {
fmt.Fprintf(env.Err, "the source %s is not a conventions suite: %s\n", ref, tree.Describe(err))
o.Close()
return nil, Failed
}
// The language is a level of its own, and a suite may keep its documents
// apart from itself. While it does not, the suite is where they lie.
if spec := o.Suite.Manifest.Language.Source; spec != "" {
ref, err := source.Parse(spec)
if err != nil {
o.Close()
fmt.Fprintf(env.Err, "%s: [language] source: %s\n", o.Suite.Manifest.Path, err)
return nil, Usage
}
langTree, err := source.Open(ref, tree.Dir())
if err != nil {
o.Close()
fmt.Fprintln(env.Err, err)
return nil, Failed
}
o.trees = append(o.trees, langTree)
o.LangRoot = langTree.Dir()
}
return o, OK
}
// loadProject reads the project manifest and says what it did not understand.
// A typo in a key costs a whole component: `dyr` instead of `dir` leaves the
// component pointing at the root of the repository, and nothing else would say
// so.
func loadProject(env Env, dir string) (*manifest.Project, ExitCode) {
m, err := manifest.LoadProject(dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return nil, Usage
}
for _, key := range m.Undecoded {
fmt.Fprintf(env.Err, "warning: %s: the key %s is unknown to the tool\n", m.Path, key)
}
return m, OK
}
// projectRoot finds the manifest of the project. A project command typed inside
// a suite does not do anything at a guess: it says where it is and names the
// command of that level.
func projectRoot(env Env, given string) (string, ExitCode) {
if given != "" {
return given, OK
}
found, err := manifest.FindProject(env.Dir)
if err == nil {
return found, OK
}
if _, suiteErr := manifest.Find(env.Dir); suiteErr == nil {
fmt.Fprintf(env.Err, "this is a conventions suite, not a project that takes copies: %s lies here, %s does not\n", manifest.Name, manifest.ProjectName)
fmt.Fprintln(env.Err, "the commands of a suite are under convy suite")
return "", Usage
}
fmt.Fprintf(env.Err, "not a project with conventions: no %s here or above\n", manifest.ProjectName)
fmt.Fprintln(env.Err, "convy init wires one up")
return "", Usage
}
// componentOf resolves the --for flag against the manifest.
func componentOf(env Env, m *manifest.Project, name string) (string, manifest.Component, ExitCode) {
got, c, err := m.Only(name)
if err != nil {
fmt.Fprintln(env.Err, err)
return "", manifest.Component{}, Usage
}
if c.Dir == "" {
fmt.Fprintf(env.Err, "the component %q names no dir, and a copy has to be written somewhere\n", got)
return "", manifest.Component{}, Usage
}
return got, c, OK
}
// components picks the components a command works on: the one named by --for,
// or every one of them.
func components(env Env, m *manifest.Project, name string) ([]string, ExitCode) {
if name != "" {
if _, ok := m.Components[name]; !ok {
fmt.Fprintf(env.Err, "the project declares no component %q; it declares: %s\n", name, strings.Join(m.Names(), ", "))
return nil, Usage
}
return []string{name}, OK
}
if len(m.Components) == 0 {
fmt.Fprintf(env.Err, "%s declares no component, and a copy is assembled for a component\n", m.Path)
return nil, Usage
}
return m.Names(), OK
}
// distinctDirs checks that no two components write into the same place. Two
// copies of one topic would otherwise collide by name, and that is an error of
// the manifest rather than a reason to rename files.
//
// One directory inside another is the same error told less plainly: whatever
// walks the outer one finds the copies of the inner, and every command that
// counts files counts them twice.
func distinctDirs(m *manifest.Project) error {
dirs := make(map[string]string, len(m.Components))
for _, name := range m.Names() {
dirs[name] = filepath.ToSlash(filepath.Clean(m.Components[name].Dir))
}
names := m.Names()
for i, a := range names {
for _, b := range names[i+1:] {
switch {
case dirs[a] == dirs[b]:
return fmt.Errorf("the components %q and %q share the directory %s: copies of one topic would collide there", a, b, dirs[a])
case within(dirs[a], dirs[b]):
return fmt.Errorf("the directory of the component %q (%s) lies inside the one of %q (%s): whatever walks the outer one finds the copies of the inner", b, dirs[b], a, dirs[a])
case within(dirs[b], dirs[a]):
return fmt.Errorf("the directory of the component %q (%s) lies inside the one of %q (%s): whatever walks the outer one finds the copies of the inner", a, dirs[a], b, dirs[b])
}
}
}
return nil
}
// within reports whether inner lies under outer.
func within(outer, inner string) bool {
return strings.HasPrefix(inner, outer+"/")
}
// exists reports whether a path is there.
func exists(name string) bool {
_, err := os.Stat(name)
return err == nil
}