Files
convy/internal/cli/suiteinit.go
T
av a597805f3c манифест набора переименован в .conventions-suite.toml
- оба манифеста стали данными, которые инструмент переписывает целиком,
  так что и наборный получает точку в начале: он служебный файл, а не
  документ репозитория
- переименование механическое, одна константа manifest.Name; литералы в
  фикстурах и ссылки в README.md и CLAUDE.md обновлены следом
2026-07-28 09:55:26 +03:00

166 lines
5.7 KiB
Go

package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/manifest"
)
// A suite starts as a manifest holding only what it knows about itself, and a
// README next to it holding everything a person needs to know to fill it in.
//
// The two are split because the manifest is data the tool rewrites on every
// `suite add` and every `suite retire`, while the README is prose nothing
// touches. Keeping the explanations inside the manifest would mean losing them
// the first time a command wrote the file.
//
// The README is a starting point, in the tool's own language, for its author to
// replace. What the suite itself is about, only its author knows.
const readmeSkeleton = `# Conventions suite
The rules live in ` + "`conventions/`" + `. What each file is and what it may do is
settled by the conventions language; this README is the place to say what this
particular suite is for and how it is kept.
## The manifest
` + "`.conventions-suite.toml`" + ` holds the identity of the suite: the language its rules are
written in, its topics and its rule prefixes. It is written by ` + "`convy`" + ` and
carries no comments — a command rewrites the whole file, and a comment would not
survive that. Explanations belong here instead.
## Topics
A topic is a set of rules about one focus of development — time, configuration,
the database schema — and it is the unit of subscription: a consumer takes it
whole. A name is never renamed and never reissued, because it lives on in
foreign repositories: in the ` + "`origin:`" + ` header of every copy and in the
subscription of every consumer.
Since the name is permanent, a topic is named after a decision and whom it
addresses rather than after the role some part of today's project plays.
## Prefixes
A prefix is four uppercase Latin letters, unique across the suite, chosen for a
file rather than derived by a formula. Rules are addressed by identifier —
` + "`KEYS-5`" + ` — with no path, so the identifier survives a file moving between
axes. A prefix is never reissued either.
The letter X in first position is reserved for the local rules of consuming
repositories. The suite never takes it, so a local prefix can never collide with
a future one here.
## Retirement
Nothing leaves the manifest. A topic or a prefix that is done moves to the
retired half together with a reason and a date, so that the name can never be
handed to something else.
`
func runSuiteInit(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite init", flag.ContinueOnError)
fs.SetOutput(env.Err)
path := fs.String("path", "", "directory of the suite; it is created if absent")
code := fs.String("lang", "", "code of the natural language the suite is written in")
version := fs.Int("language-version", 1, "version of the conventions language")
if err := fs.Parse(args); err != nil {
return Usage
}
fields := []Field{{
Flag: "path",
Ask: "Directory of the suite",
Hint: "Where the suite will live. The directory is created if it is not there yet.",
Default: ".",
}, {
Flag: "lang",
Ask: "Natural language of the suite",
Hint: "The language the rules are written in. It settles the key words: ДОЛЖЕН and ПОЧЕМУ for ru, MUST and WHY for en.",
Default: manifest.DefaultLanguageCode,
Check: func(v string) error {
_, err := lang.Lookup(*version, v)
return err
},
}}
given := map[string]string{"path": *path, "lang": *code}
if len(args) == 0 {
if !env.Interactive {
fmt.Fprintln(env.Err, "convy suite init without arguments asks questions, and there is no terminal to ask on; pass --path and --lang")
return Usage
}
if err := askAll(env, fields, given); err != nil {
return Usage
}
} else if err := resolve(fields, given); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
root := given["path"]
if _, err := os.Stat(filepath.Join(root, manifest.Name)); err == nil {
fmt.Fprintf(env.Err, "%s already holds %s: this is a suite already\n", root, manifest.Name)
return Usage
}
if err := os.MkdirAll(filepath.Join(root, "conventions"), 0o755); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
name := filepath.Join(root, manifest.Name)
m := &manifest.Manifest{
Language: manifest.Language{Version: *version, Lang: given["lang"]},
Path: name,
}
if err := m.Save(); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
fmt.Fprintf(env.Out, "\ncreated %s\ncreated %s\n", name, filepath.Join(root, "conventions"))
// The README is written only when there is none: it is a starting point,
// and a starting point that overwrites what somebody already wrote is not
// one.
readme := filepath.Join(root, "README.md")
if !exists(readme) {
if err := os.WriteFile(readme, []byte(readmeSkeleton), 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
fmt.Fprintf(env.Out, "created %s\n", readme)
}
fmt.Fprintf(env.Out, "\nthe suite speaks %s, conventions language version %d\n", given["lang"], *version)
fmt.Fprint(env.Out, `
next:
rewrite README.md — it says what a topic and a prefix are, not what this suite is for
write the two documents about the language and name them in [language]
convy suite add add the first convention
convy suite check verify the suite holds together
`)
return OK
}
// askAll walks the fields in interactive mode, keeping answers already given on
// the command line.
func askAll(env Env, fields []Field, given map[string]string) error {
d := newDialogue(env)
for _, f := range fields {
if given[f.Flag] != "" {
continue
}
answer, err := d.ask(f)
if err != nil {
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
return err
}
given[f.Flag] = answer
}
return nil
}