проектные команды и ссылки на источник
- заведён internal/source: уровни ссылаются друг на друга путём на диске или git-репозиторием, ревизия закрепляется хвостом #ref; клон делается заново и удаляется, кэша нет - добавлены init, add, pull, list, check в проекте — манифест .conventions.toml, сборка копий по разу на компонент, маркер локальной части, READING.md рядом - проверки формы развязаны с набором: принимают lang.Vocabulary, а язык копии узнаётся по строке о версии — манифеста рядом с ней нет
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
"git.vakhrushev.me/av/convy/internal/project"
|
||||
)
|
||||
|
||||
// convy add does two things at once, and they are one thing: it writes the
|
||||
// subscription into the manifest and assembles the file. A subscription that
|
||||
// left no file behind, or a file nothing subscribed to, is the state the model
|
||||
// has no name for.
|
||||
|
||||
func runAdd(env Env, args []string) ExitCode {
|
||||
fs := flag.NewFlagSet("convy add", flag.ContinueOnError)
|
||||
fs.SetOutput(env.Err)
|
||||
root := fs.String("root", "", "root of the project; it is looked up upwards by default")
|
||||
forComponent := fs.String("for", "", "component to assemble for; needed when there is more than one")
|
||||
topicFlag := fs.String("topic", "", "topic to subscribe to; may also be given as the first argument")
|
||||
|
||||
// The topic is taken off the front before the flags are parsed. The flag
|
||||
// package stops at the first argument that is not a flag, so "convy add
|
||||
// time --for backend" — the form the model writes — would otherwise lose
|
||||
// the component silently.
|
||||
topic := ""
|
||||
rest := args
|
||||
if len(rest) > 0 && !strings.HasPrefix(rest[0], "-") {
|
||||
topic, rest = rest[0], rest[1:]
|
||||
}
|
||||
if err := fs.Parse(rest); err != nil {
|
||||
return Usage
|
||||
}
|
||||
if *topicFlag != "" {
|
||||
if topic != "" && topic != *topicFlag {
|
||||
fmt.Fprintf(env.Err, "the topic is named twice and differently: %q and %q\n", topic, *topicFlag)
|
||||
return Usage
|
||||
}
|
||||
topic = *topicFlag
|
||||
}
|
||||
if rest := fs.Args(); len(rest) > 0 {
|
||||
if topic != "" && topic != rest[0] {
|
||||
fmt.Fprintf(env.Err, "the topic is named twice and differently: %q and %q\n", topic, rest[0])
|
||||
return Usage
|
||||
}
|
||||
topic = rest[0]
|
||||
}
|
||||
|
||||
o, code := openProject(env, *root)
|
||||
if code != OK {
|
||||
return code
|
||||
}
|
||||
defer o.Close()
|
||||
|
||||
if err := distinctDirs(o.Manifest); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Usage
|
||||
}
|
||||
|
||||
name, c, code := componentOf(env, o.Manifest, *forComponent)
|
||||
if code != OK {
|
||||
return code
|
||||
}
|
||||
|
||||
if topic == "" {
|
||||
if !env.Interactive {
|
||||
fmt.Fprintln(env.Err, "convy add without arguments asks which topic, and there is no terminal to ask on; name the topic as an argument")
|
||||
return Usage
|
||||
}
|
||||
answer, err := newDialogue(env).ask(topicField(o, c))
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
|
||||
return Usage
|
||||
}
|
||||
topic = answer
|
||||
}
|
||||
|
||||
if !o.Suite.Manifest.TopicLive(topic) {
|
||||
if o.Suite.Manifest.TopicRetired(topic) {
|
||||
fmt.Fprintf(env.Err, "the suite has retired the topic %q: %s\n", topic, o.Suite.Manifest.Topics.Retired[topic])
|
||||
return Usage
|
||||
}
|
||||
fmt.Fprintf(env.Err, "the suite declares no topic %q; it declares: %s\n", topic, strings.Join(o.Suite.Manifest.LiveTopics(), ", "))
|
||||
return Usage
|
||||
}
|
||||
if c.Subscribed(topic) {
|
||||
fmt.Fprintf(env.Err, "the component %q is subscribed to %q already; convy pull reassembles it\n", name, topic)
|
||||
return Usage
|
||||
}
|
||||
|
||||
made, err := project.Assemble(o.Suite, o.Root, c, topic)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
|
||||
// The manifest is written after the file: a subscription recorded against
|
||||
// an assembly that failed would send the next pull looking for a copy that
|
||||
// was never made.
|
||||
source, err := os.ReadFile(o.Manifest.Path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
updated, err := manifest.AddToList(source, "components."+name, "topics", topic)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
if err := os.WriteFile(o.Manifest.Path, updated, 0o644); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
|
||||
guide, err := project.Reading(o.LangRoot, o.Suite.Manifest.Language.Reading, o.Root, c.Dir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(env.Err, "warning: %s\n", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(env.Out, "\n%s → %s\n", topic, made.Path)
|
||||
for _, layer := range made.Layers {
|
||||
fmt.Fprintf(env.Out, " %s\n", layer)
|
||||
}
|
||||
if guide != "" {
|
||||
fmt.Fprintf(env.Out, "\n%s refreshed\n", guide)
|
||||
}
|
||||
fmt.Fprintf(env.Out, "subscribed the component %q in %s\n", name, manifest.ProjectName)
|
||||
return OK
|
||||
}
|
||||
|
||||
// topicField offers the topics the component has not taken yet.
|
||||
func topicField(o *opened, c manifest.Component) Field {
|
||||
var free []string
|
||||
for _, t := range o.Suite.Manifest.LiveTopics() {
|
||||
if !c.Subscribed(t) {
|
||||
free = append(free, t)
|
||||
}
|
||||
}
|
||||
return Field{
|
||||
Flag: "topic",
|
||||
Ask: "Topic",
|
||||
Hint: "A topic is taken whole: the file gathers every layer of it the component fits. " + describeTopics(o, free),
|
||||
Options: free,
|
||||
Check: func(v string) error {
|
||||
if !o.Suite.Manifest.TopicLive(v) {
|
||||
return fmt.Errorf("the suite declares no topic %q", v)
|
||||
}
|
||||
if c.Subscribed(v) {
|
||||
return fmt.Errorf("this component is subscribed to %q already", v)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func describeTopics(o *opened, free []string) string {
|
||||
if len(free) == 0 {
|
||||
return "The component is subscribed to everything the suite has."
|
||||
}
|
||||
parts := make([]string, 0, len(free))
|
||||
for _, t := range free {
|
||||
parts = append(parts, fmt.Sprintf("%s — %s", t, o.Suite.Manifest.Topics.Live[t]))
|
||||
}
|
||||
return "On offer: " + strings.Join(parts, "; ") + "."
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/check"
|
||||
"git.vakhrushev.me/av/convy/internal/doc"
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
"git.vakhrushev.me/av/convy/internal/project"
|
||||
)
|
||||
|
||||
// convy check stays at the top level and reaches for no suite. The form of a
|
||||
// rule is one and the same, the local rules of the repository on X prefixes are
|
||||
// written by that same form, and checking what lies here has to work without a
|
||||
// network and without knowing where the copies came from.
|
||||
|
||||
func runCheck(env Env, args []string) ExitCode {
|
||||
fs := flag.NewFlagSet("convy check", flag.ContinueOnError)
|
||||
fs.SetOutput(env.Err)
|
||||
root := fs.String("root", "", "root of the project; it is looked up upwards by default")
|
||||
forComponent := fs.String("for", "", "component to check; every one of them by default")
|
||||
quiet := fs.Bool("quiet", false, "print findings only")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return Usage
|
||||
}
|
||||
|
||||
dir, code := projectRoot(env, *root)
|
||||
if code != OK {
|
||||
return code
|
||||
}
|
||||
m, err := manifest.LoadProject(dir)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Usage
|
||||
}
|
||||
names, code := components(env, m, *forComponent)
|
||||
if code != OK {
|
||||
return code
|
||||
}
|
||||
|
||||
var docs []*doc.Document
|
||||
var broken []error
|
||||
for _, name := range names {
|
||||
found, errs := copies(dir, m.Components[name].Dir)
|
||||
docs = append(docs, found...)
|
||||
broken = append(broken, errs...)
|
||||
}
|
||||
|
||||
rep := check.Copies(docs)
|
||||
for _, err := range broken {
|
||||
fmt.Fprintf(env.Err, "%s\n", err)
|
||||
}
|
||||
printCopyReport(env.Out, rep, len(docs), len(names), *quiet)
|
||||
if rep.Errors() > 0 || len(broken) > 0 {
|
||||
return Failed
|
||||
}
|
||||
return OK
|
||||
}
|
||||
|
||||
// copies collects the assembled conventions of one component directory.
|
||||
//
|
||||
// What is a copy is decided by the origin key rather than by the name of the
|
||||
// file: README.md belongs to the repository, READING.md belongs to the suite,
|
||||
// and a file whose origin key was taken away has become a document of the
|
||||
// repository — none of the three answers to the form of a rule.
|
||||
func copies(root, dir string) ([]*doc.Document, []error) {
|
||||
var docs []*doc.Document
|
||||
var broken []error
|
||||
base := filepath.Join(root, filepath.FromSlash(dir))
|
||||
|
||||
err := filepath.WalkDir(base, func(name string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".md" {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
d, err := doc.Load(rel, name)
|
||||
if err != nil {
|
||||
broken = append(broken, err)
|
||||
return nil
|
||||
}
|
||||
if d.Front.Origin == "" {
|
||||
return nil
|
||||
}
|
||||
docs = append(docs, d)
|
||||
return nil
|
||||
})
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
broken = append(broken, fmt.Errorf("walking %s: %w", dir, err))
|
||||
}
|
||||
sort.Slice(docs, func(i, j int) bool { return docs[i].Path < docs[j].Path })
|
||||
return docs, broken
|
||||
}
|
||||
|
||||
func printCopyReport(w io.Writer, rep *check.Report, files, comps int, quiet bool) {
|
||||
findings := rep.Findings()
|
||||
current := ""
|
||||
for _, f := range findings {
|
||||
if f.Path != current {
|
||||
if current != "" {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintf(w, "%s\n", f.Path)
|
||||
current = f.Path
|
||||
}
|
||||
where := ""
|
||||
if f.Line > 0 {
|
||||
where = fmt.Sprintf(":%d", f.Line)
|
||||
}
|
||||
fmt.Fprintf(w, " %s%s %s [%s]\n", f.Severity, where, f.Msg, f.Family)
|
||||
}
|
||||
|
||||
if quiet {
|
||||
return
|
||||
}
|
||||
if len(findings) > 0 {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintf(w, "project: %s in %s\n", plural(files, "file"), plural(comps, "component"))
|
||||
switch {
|
||||
case rep.Errors() > 0:
|
||||
fmt.Fprintf(w, "errors: %d, warnings: %d\n", rep.Errors(), rep.Warnings())
|
||||
case rep.Warnings() > 0:
|
||||
fmt.Fprintf(w, "no errors, warnings: %d\n", rep.Warnings())
|
||||
case files == 0:
|
||||
fmt.Fprintf(w, "nothing to check: no file carries an origin key; convy pull assembles the copies\n")
|
||||
default:
|
||||
fmt.Fprintf(w, "the copies hold the form; everything below %s is the repository's own\n", project.LocalMarker)
|
||||
}
|
||||
}
|
||||
+15
-7
@@ -48,9 +48,16 @@ func Run(env Env, args []string) ExitCode {
|
||||
switch args[0] {
|
||||
case "suite":
|
||||
return runSuite(env, args[1:])
|
||||
case "add", "pull", "list", "check":
|
||||
fmt.Fprintf(env.Err, "the %q command is not implemented yet\n", args[0])
|
||||
return Usage
|
||||
case "init":
|
||||
return runInit(env, args[1:])
|
||||
case "add":
|
||||
return runAdd(env, args[1:])
|
||||
case "pull":
|
||||
return runPull(env, args[1:])
|
||||
case "list":
|
||||
return runList(env, args[1:])
|
||||
case "check":
|
||||
return runCheck(env, args[1:])
|
||||
case "help", "-h", "--help":
|
||||
usage(env.Out)
|
||||
return OK
|
||||
@@ -90,10 +97,11 @@ func usage(w io.Writer) {
|
||||
fmt.Fprint(w, `convy — tending development conventions.
|
||||
|
||||
In a project:
|
||||
convy add <topic> subscribe and assemble (not implemented)
|
||||
convy pull reassemble what is subscribed (not implemented)
|
||||
convy list what is wired up and available (not implemented)
|
||||
convy check check the form of what is here (not implemented)
|
||||
convy init wire up conventions: the source and the first component
|
||||
convy add <topic> subscribe and assemble
|
||||
convy pull reassemble what is subscribed
|
||||
convy list what is wired up and what else the suite has
|
||||
convy check check the form of what is here
|
||||
|
||||
In a suite:
|
||||
convy suite init start a suite: a directory and a manifest
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"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"
|
||||
)
|
||||
|
||||
// projectSkeleton is what a project manifest starts as. It is written as text
|
||||
// with its comments in place for the same reason the suite manifest is: the
|
||||
// file is read far more often than a tool touches it, and what a component is
|
||||
// for is not deducible from three keys.
|
||||
const projectSkeleton = `# What this repository takes from a conventions suite.
|
||||
#
|
||||
# Two manifests exist in the model, each named after what it describes:
|
||||
# suite.toml in a suite describes the suite itself; .conventions.toml here
|
||||
# describes the subscription — where the copies come from and who takes what.
|
||||
#
|
||||
# Copies are committed. Nothing is fetched on the fly, and the answer to "what
|
||||
# did it look like last time" is given by git rather than by a lock file.
|
||||
|
||||
# Where the copies come from: a path on disk, relative to this file or absolute,
|
||||
# or a git repository over http or https. A trailing #branch, #tag or #commit
|
||||
# pins a revision.
|
||||
source = "%s"
|
||||
|
||||
# ─── Components ─────────────────────────────────────────────────────────────
|
||||
#
|
||||
# A component is a region of the repository where every selected layer holds at
|
||||
# once: one language, one set of tools, one kind of application. Each one has
|
||||
# its own directory, and the directories differ — that is the only thing that
|
||||
# tells two copies of one topic apart.
|
||||
#
|
||||
# lang and stack choose the layers: a layer travels when the axis it declares is
|
||||
# among them, and a layer declaring no axis travels always. topics is the
|
||||
# subscription itself, by the names the suite gives its topics.
|
||||
`
|
||||
|
||||
func runInit(env Env, args []string) ExitCode {
|
||||
fs := flag.NewFlagSet("convy init", flag.ContinueOnError)
|
||||
fs.SetOutput(env.Err)
|
||||
root := fs.String("root", "", "root of the project; the current directory by default")
|
||||
from := fs.String("source", "", "reference to the suite: a path on disk or a git repository")
|
||||
component := fs.String("component", "", "name of the first component")
|
||||
dir := fs.String("dir", "", "directory the copies of that component go into")
|
||||
langAxis := fs.String("lang", "", "language of the component")
|
||||
stackAxis := fs.String("stack", "", "stack of the component, comma-separated")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return Usage
|
||||
}
|
||||
|
||||
where := *root
|
||||
if where == "" {
|
||||
where = env.Dir
|
||||
}
|
||||
if exists(filepath.Join(where, manifest.ProjectName)) {
|
||||
fmt.Fprintf(env.Err, "%s already holds %s: the conventions are wired up already\n", where, manifest.ProjectName)
|
||||
fmt.Fprintln(env.Err, "convy add subscribes to one more topic")
|
||||
return Usage
|
||||
}
|
||||
|
||||
given := map[string]string{
|
||||
"source": *from, "component": *component, "dir": *dir,
|
||||
"lang": *langAxis, "stack": *stackAxis,
|
||||
}
|
||||
if len(args) == 0 {
|
||||
if !env.Interactive {
|
||||
fmt.Fprintln(env.Err, "convy init without arguments asks questions, and there is no terminal to ask on; pass --source, --component and --dir")
|
||||
return Usage
|
||||
}
|
||||
if err := askAll(env, initFields(), given); err != nil {
|
||||
return Usage
|
||||
}
|
||||
} else if err := resolve(initFields(), given); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Usage
|
||||
}
|
||||
|
||||
// The suite is reached before the manifest is written. A manifest naming a
|
||||
// suite nobody can reach passes every check the tool has and helps no one.
|
||||
ref, err := source.Parse(given["source"])
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Usage
|
||||
}
|
||||
tree, err := source.Open(ref, where)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
defer tree.Close()
|
||||
|
||||
s, 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))
|
||||
return Failed
|
||||
}
|
||||
|
||||
entries := [][2]string{{"dir", quote(given["dir"])}}
|
||||
if list := split(given["lang"]); len(list) > 0 {
|
||||
entries = append(entries, [2]string{"lang", array(list)})
|
||||
}
|
||||
if list := split(given["stack"]); len(list) > 0 {
|
||||
entries = append(entries, [2]string{"stack", array(list)})
|
||||
}
|
||||
entries = append(entries, [2]string{"topics", "[]"})
|
||||
|
||||
body, err := manifest.AddTable([]byte(fmt.Sprintf(projectSkeleton, given["source"])),
|
||||
"components."+given["component"], entries)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
if err := os.MkdirAll(where, 0o755); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
name := filepath.Join(where, manifest.ProjectName)
|
||||
if err := os.WriteFile(name, body, 0o644); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
|
||||
fmt.Fprintf(env.Out, "\ncreated %s\n", name)
|
||||
fmt.Fprintf(env.Out, "the suite speaks %s, conventions language version %d\n",
|
||||
s.Manifest.Language.Lang, s.Manifest.Language.Version)
|
||||
|
||||
topics := s.Manifest.LiveTopics()
|
||||
if len(topics) > 0 {
|
||||
fmt.Fprintf(env.Out, "\n%s to take from:\n", plural(len(topics), "topic"))
|
||||
for _, t := range topics {
|
||||
fmt.Fprintf(env.Out, " %-20s %s\n", t, s.Manifest.Topics.Live[t])
|
||||
}
|
||||
}
|
||||
fmt.Fprint(env.Out, `
|
||||
next:
|
||||
convy add <topic> subscribe and assemble
|
||||
convy list what is wired up and what else is there
|
||||
`)
|
||||
return OK
|
||||
}
|
||||
|
||||
func initFields() []Field {
|
||||
return []Field{{
|
||||
Flag: "source",
|
||||
Ask: "Where the copies come from",
|
||||
Hint: "A path to the suite on disk — relative to this repository or absolute — or a git repository over http or https. A trailing #branch, #tag or #commit pins a revision.",
|
||||
}, {
|
||||
Flag: "component",
|
||||
Ask: "Name of the component",
|
||||
Hint: "A region of the repository where all the chosen layers hold at once: one language, one set of tools. The name is not internal — it is how the tool answers what it assembled and where.",
|
||||
Check: func(v string) error {
|
||||
if !nameRe.MatchString(v) {
|
||||
return fmt.Errorf("a component is named by a plain identifier: letters, digits, a dash")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}, {
|
||||
Flag: "dir",
|
||||
Ask: "Directory of the copies",
|
||||
Hint: "Where the assembled files go. Every component has its own, and two components never share one: the copies of a topic would collide by name.",
|
||||
Default: "docs/conventions",
|
||||
}, {
|
||||
Flag: "lang",
|
||||
Ask: "Language of the component",
|
||||
Hint: "Chooses the language layers. Empty when the suite is flat and has no axes at all.",
|
||||
Optional: true,
|
||||
}, {
|
||||
Flag: "stack",
|
||||
Ask: "Stack of the component",
|
||||
Hint: "Chooses the stack layers — the storage, the transport, the tools. Several are allowed, comma-separated: sqlite and postgres hold together, being different tables of one service.",
|
||||
Optional: true,
|
||||
}}
|
||||
}
|
||||
|
||||
func quote(s string) string {
|
||||
return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"`
|
||||
}
|
||||
|
||||
func array(values []string) string {
|
||||
parts := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
parts[i] = quote(v)
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
"git.vakhrushev.me/av/convy/internal/project"
|
||||
)
|
||||
|
||||
// convy list answers two questions in one view: what this repository takes and
|
||||
// what the suite has that it does not. The second half is the reason the
|
||||
// command reaches the suite at all — a listing of the manifest alone is the
|
||||
// manifest, and reading it needs no tool.
|
||||
|
||||
func runList(env Env, args []string) ExitCode {
|
||||
fs := flag.NewFlagSet("convy list", flag.ContinueOnError)
|
||||
fs.SetOutput(env.Err)
|
||||
root := fs.String("root", "", "root of the project; it is looked up upwards by default")
|
||||
forComponent := fs.String("for", "", "component to show; every one of them by default")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return Usage
|
||||
}
|
||||
|
||||
o, code := openProject(env, *root)
|
||||
if code != OK {
|
||||
return code
|
||||
}
|
||||
defer o.Close()
|
||||
|
||||
names, code := components(env, o.Manifest, *forComponent)
|
||||
if code != OK {
|
||||
return code
|
||||
}
|
||||
|
||||
fmt.Fprintf(env.Out, "source %s\n", o.Manifest.Source)
|
||||
fmt.Fprintf(env.Out, "%s, language version %d (%s)\n",
|
||||
plural(len(o.Suite.Manifest.LiveTopics()), "topic"),
|
||||
o.Suite.Manifest.Language.Version, o.Suite.Manifest.Language.Lang)
|
||||
|
||||
for _, name := range names {
|
||||
c := o.Manifest.Components[name]
|
||||
fmt.Fprintf(env.Out, "\n%s — %s%s\n", name, c.Dir, axisOf(c))
|
||||
listComponent(env, o, c)
|
||||
}
|
||||
return OK
|
||||
}
|
||||
|
||||
func listComponent(env Env, o *opened, c manifest.Component) {
|
||||
if len(c.Topics) == 0 {
|
||||
fmt.Fprintln(env.Out, " subscribed to nothing yet")
|
||||
}
|
||||
for _, topic := range c.Topics {
|
||||
if !o.Suite.Manifest.TopicLive(topic) {
|
||||
fmt.Fprintf(env.Out, " %-20s the suite declares no such topic any more\n", topic)
|
||||
continue
|
||||
}
|
||||
taken, _ := o.Suite.Assemble(topic, project.Axis(c))
|
||||
file := filepath.ToSlash(filepath.Join(c.Dir, topic+".md"))
|
||||
mark := " "
|
||||
if !exists(filepath.Join(o.Root, filepath.FromSlash(file))) {
|
||||
mark = "!"
|
||||
}
|
||||
fmt.Fprintf(env.Out, "%s %-20s %-16s %s\n", mark, topic, plural(len(taken), "layer"),
|
||||
o.Suite.Manifest.Topics.Live[topic])
|
||||
if mark == "!" {
|
||||
fmt.Fprintf(env.Out, " %-20s subscribed, and no file: convy pull assembles it\n", "")
|
||||
}
|
||||
}
|
||||
|
||||
var free []string
|
||||
for _, topic := range o.Suite.Manifest.LiveTopics() {
|
||||
if !c.Subscribed(topic) {
|
||||
free = append(free, topic)
|
||||
}
|
||||
}
|
||||
if len(free) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(env.Out, "\n not taken:")
|
||||
for _, topic := range free {
|
||||
taken, _ := o.Suite.Assemble(topic, project.Axis(c))
|
||||
if len(taken) == 0 {
|
||||
fmt.Fprintf(env.Out, "· %-20s %-16s %s\n", topic, "no layer fits",
|
||||
o.Suite.Manifest.Topics.Live[topic])
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(env.Out, "· %-20s %-16s %s\n", topic, plural(len(taken), "layer"),
|
||||
o.Suite.Manifest.Topics.Live[topic])
|
||||
}
|
||||
}
|
||||
|
||||
// axisOf describes the axis of a component the way the suite writes it.
|
||||
func axisOf(c manifest.Component) string {
|
||||
var parts []string
|
||||
if len(c.Lang) > 0 {
|
||||
parts = append(parts, "lang="+strings.Join(c.Lang, ","))
|
||||
}
|
||||
if len(c.Stack) > 0 {
|
||||
parts = append(parts, "stack="+strings.Join(c.Stack, ","))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " (" + strings.Join(parts, " ") + ")"
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"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, 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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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\n", name)
|
||||
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 directory.
|
||||
// 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.
|
||||
func distinctDirs(m *manifest.Project) error {
|
||||
seen := make(map[string]string)
|
||||
for _, name := range m.Names() {
|
||||
dir := filepath.ToSlash(filepath.Clean(m.Components[name].Dir))
|
||||
if other, taken := seen[dir]; taken {
|
||||
return fmt.Errorf("the components %q and %q share the directory %s: copies of one topic would collide there", other, name, dir)
|
||||
}
|
||||
seen[dir] = name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// exists reports whether a path is there.
|
||||
func exists(name string) bool {
|
||||
_, err := os.Stat(name)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/cli"
|
||||
)
|
||||
|
||||
// readingGuide stands in for what a suite puts next to its copies: the short
|
||||
// account of the language, naming every word the version line names.
|
||||
const readingGuide = `# Как читать конвенцию
|
||||
|
||||
Слова толкуются так, и только когда написаны заглавными.
|
||||
|
||||
| Слово | Значение |
|
||||
|---|---|
|
||||
| ДОЛЖЕН | требование |
|
||||
| НЕ ДОЛЖЕН | запрет |
|
||||
| СЛЕДУЕТ | рекомендация |
|
||||
| НЕ СЛЕДУЕТ | рекомендация против |
|
||||
| ДОПУСКАЕТСЯ | разрешение |
|
||||
| ПОЧЕМУ | обоснование |
|
||||
| ПРИМЕРЫ | иллюстрация |
|
||||
| МЕХАНИЗИРОВАНО | чем проверяется |
|
||||
| СНЯТО | заглушка снятого правила |
|
||||
`
|
||||
|
||||
// subscribable is a suite a project can take from: the fixture of the retire
|
||||
// tests plus the guide that travels next to the copies.
|
||||
func subscribable(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := retirable(t)
|
||||
appendRules(t, root, "conventions/lang/go/time.md", `
|
||||
### GTIM-1. «Сейчас» берётся у слоя хранилища
|
||||
|
||||
**ДОЛЖЕН.** Текущее время приходит из `+"`store.Now()`"+`.
|
||||
|
||||
**ПОЧЕМУ.** Единая точка даёт гарантированный UTC и один формат.
|
||||
`)
|
||||
|
||||
name := filepath.Join(root, "READING.md")
|
||||
if err := os.WriteFile(name, []byte(readingGuide), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := read(t, root, "suite.toml")
|
||||
body = strings.Replace(body, `# reading = "READING.md"`, `reading = "READING.md"`, 1)
|
||||
if err := os.WriteFile(filepath.Join(root, "suite.toml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkClean(t, root)
|
||||
return root
|
||||
}
|
||||
|
||||
// wired builds a project taking from that suite, and returns its root.
|
||||
func wired(t *testing.T, suiteRoot string, args ...string) string {
|
||||
t.Helper()
|
||||
root := filepath.Join(t.TempDir(), "app")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all := append([]string{"init", "--source", suiteRoot}, args...)
|
||||
if code, out := run(t, root, "", false, all...); code != cli.OK {
|
||||
t.Fatalf("convy init returned %d: %s", code, out)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestInitAddAndPullBuildACopyOutOfTheLayers(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
|
||||
code, out := run(t, root, "", false, "add", "time")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("convy add returned %d: %s", code, out)
|
||||
}
|
||||
if !strings.Contains(out, "docs/conventions/time.md") {
|
||||
t.Errorf("the assembled file is not named:\n%s", out)
|
||||
}
|
||||
|
||||
body := read(t, root, "docs/conventions/time.md")
|
||||
for _, want := range []string{
|
||||
"origin: time",
|
||||
"### TIME-1.",
|
||||
"#### GTIM-1.",
|
||||
"<!-- conv:local -->",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the copy lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "prefix: TIME") {
|
||||
t.Errorf("the front matter of a layer travelled into the copy:\n%s", body)
|
||||
}
|
||||
|
||||
// The guide to reading a rule belongs to the suite and travels with the
|
||||
// copies; README.md in the same directory belongs to the repository.
|
||||
if guide := read(t, root, "docs/conventions/READING.md"); !strings.Contains(guide, "ДОПУСКАЕТСЯ") {
|
||||
t.Errorf("the reading guide did not travel:\n%s", guide)
|
||||
}
|
||||
|
||||
if manifest := read(t, root, ".conventions.toml"); !strings.Contains(manifest, `topics = ["time"]`) {
|
||||
t.Errorf("the subscription was not written:\n%s", manifest)
|
||||
}
|
||||
|
||||
if code, out := run(t, root, "", false, "check"); code != cli.OK {
|
||||
t.Fatalf("checking what the tool assembled returned %d: %s", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the marker: what the repository wrote survives, what the
|
||||
// suite wrote is replaced.
|
||||
func TestPullKeepsTheLocalPartAndReplacesTheRest(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
run(t, root, "", false, "add", "time")
|
||||
|
||||
name := filepath.Join(root, "docs", "conventions", "time.md")
|
||||
body := read(t, root, "docs/conventions/time.md")
|
||||
body = strings.Replace(body, "**ДОЛЖЕН.** Момент времени", "**ДОЛЖЕН.** Правка выше маркера", 1)
|
||||
body += "\nTIME-1 — МЕХАНИЗИРОВАНО: `internal/archrules`.\n"
|
||||
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
code, out := run(t, root, "", false, "pull")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("convy pull returned %d: %s", code, out)
|
||||
}
|
||||
if !strings.Contains(out, "local part kept") {
|
||||
t.Errorf("the pull did not say the local part survived:\n%s", out)
|
||||
}
|
||||
|
||||
body = read(t, root, "docs/conventions/time.md")
|
||||
if strings.Contains(body, "Правка выше маркера") {
|
||||
t.Errorf("an edit above the marker survived, and it is declared not to:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "МЕХАНИЗИРОВАНО: `internal/archrules`") {
|
||||
t.Errorf("the local part was lost:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A layer travels when the axis it declares agrees with the component; the base
|
||||
// layer travels always.
|
||||
func TestAComponentTakesOnlyTheLayersThatFitIt(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "web", "--dir", "web/docs/conventions", "--lang", "javascript")
|
||||
run(t, root, "", false, "add", "time")
|
||||
|
||||
body := read(t, root, "web/docs/conventions/time.md")
|
||||
if !strings.Contains(body, "### TIME-1.") {
|
||||
t.Errorf("the base layer did not travel:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "GTIM-1") {
|
||||
t.Errorf("a go layer travelled into a javascript component:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCommandsRefuseWhatTheyCannotDo(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
run(t, root, "", false, "add", "time")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{{
|
||||
name: "a topic the suite does not declare",
|
||||
args: []string{"add", "billing"},
|
||||
want: "declares no topic",
|
||||
}, {
|
||||
name: "a topic taken twice",
|
||||
args: []string{"add", "time"},
|
||||
want: "subscribed to",
|
||||
}, {
|
||||
name: "a component that is not there",
|
||||
args: []string{"pull", "--for", "mobile"},
|
||||
want: "no component",
|
||||
}}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
code, out := run(t, root, "", false, tc.args...)
|
||||
if code == cli.OK {
|
||||
t.Fatalf("the command went through:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, tc.want) {
|
||||
t.Errorf("the refusal does not say %q:\n%s", tc.want, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A file that lost its origin key has become a document of the repository, and
|
||||
// assembly has no business overwriting it.
|
||||
func TestPullDoesNotOverwriteWhatIsNoLongerACopy(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
run(t, root, "", false, "add", "time")
|
||||
|
||||
name := filepath.Join(root, "docs", "conventions", "time.md")
|
||||
body := read(t, root, "docs/conventions/time.md")
|
||||
body = strings.Replace(body, "---\norigin: time\n---\n\n", "", 1)
|
||||
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
code, out := run(t, root, "", false, "pull")
|
||||
if code == cli.OK {
|
||||
t.Fatalf("the pull overwrote a document of the repository:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "no origin key") {
|
||||
t.Errorf("the refusal does not say why:\n%s", out)
|
||||
}
|
||||
if got := read(t, root, "docs/conventions/time.md"); strings.Contains(got, "origin: time") {
|
||||
t.Errorf("the file was rewritten anyway:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The context is read off the manifest lying next to you, and nothing is done
|
||||
// at a guess.
|
||||
func TestAProjectCommandInsideASuiteSaysWhereItIs(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
code, out := run(t, suiteRoot, "", false, "pull")
|
||||
if code == cli.OK {
|
||||
t.Fatalf("convy pull ran inside a suite:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "convy suite") {
|
||||
t.Errorf("the refusal does not point at the commands of a suite:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListShowsWhatIsTakenAndWhatIsNot(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
run(t, root, "", false, "add", "time")
|
||||
|
||||
code, out := run(t, root, "", false, "list")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("convy list returned %d: %s", code, out)
|
||||
}
|
||||
for _, want := range []string{"backend — docs/conventions", "lang=go", "time", "2 layers", "not taken", "logging"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("the listing lacks %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The topic stands in front of the flags, and the flag package stops at the
|
||||
// first argument that is not one — so "convy add time --for backend", the form
|
||||
// the model writes, has to keep the component rather than drop it in silence.
|
||||
func TestAddTakesTheTopicBeforeTheFlags(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "backend/docs", "--lang", "go")
|
||||
|
||||
body := read(t, root, ".conventions.toml")
|
||||
body += "\n[components.web]\ndir = \"web/docs\"\ntopics = []\n"
|
||||
if err := os.WriteFile(filepath.Join(root, ".conventions.toml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
code, out := run(t, root, "", false, "add", "logging", "--for", "web")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("convy add returned %d: %s", code, out)
|
||||
}
|
||||
if !strings.Contains(out, "web/docs/logging.md") {
|
||||
t.Errorf("the copy did not land in the named component:\n%s", out)
|
||||
}
|
||||
if manifest := read(t, root, ".conventions.toml"); !strings.Contains(manifest, `topics = ["logging"]`) {
|
||||
t.Errorf("the subscription went to the wrong component:\n%s", manifest)
|
||||
}
|
||||
}
|
||||
|
||||
// Distinct directories are the only thing that tells two copies of one topic
|
||||
// apart, so sharing one is an error of the manifest rather than a reason to
|
||||
// rename files.
|
||||
func TestTwoComponentsMayNotShareADirectory(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
|
||||
body := read(t, root, ".conventions.toml")
|
||||
body += "\n[components.web]\ndir = \"docs/conventions\"\ntopics = []\n"
|
||||
if err := os.WriteFile(filepath.Join(root, ".conventions.toml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
code, out := run(t, root, "", false, "pull")
|
||||
if code == cli.OK {
|
||||
t.Fatalf("two components wrote into one directory:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "share the directory") {
|
||||
t.Errorf("the refusal does not say what collides:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Bare, a project command asks; that mode is for a person, and the one with
|
||||
// flags is for agents and scripts.
|
||||
func TestAddAsksWhichTopicWhenToldNothing(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
|
||||
code, out := run(t, root, "time\n", true, "add")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("the dialogue returned %d: %s", code, out)
|
||||
}
|
||||
if !strings.Contains(out, "Topic") || !strings.Contains(out, "время") {
|
||||
t.Errorf("the question carries no hint about what is on offer:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "docs/conventions/time.md") {
|
||||
t.Errorf("the dialogue assembled nothing:\n%s", out)
|
||||
}
|
||||
|
||||
// Without a terminal the same bare command refuses instead of blocking on
|
||||
// an answer nobody is there to give.
|
||||
code, out = run(t, root, "", false, "add")
|
||||
if code == cli.OK {
|
||||
t.Fatalf("a bare command went through with no terminal:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "no terminal") {
|
||||
t.Errorf("the refusal does not say why:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The language is a level of its own, and a suite may keep its documents apart.
|
||||
// Nothing else changes: the guide still travels next to the copies.
|
||||
func TestTheLanguageMayLiveApartFromTheSuite(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
|
||||
apart := filepath.Join(filepath.Dir(suiteRoot), "language")
|
||||
if err := os.MkdirAll(apart, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(filepath.Join(suiteRoot, "READING.md"), filepath.Join(apart, "READING.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := read(t, suiteRoot, "suite.toml")
|
||||
body = strings.Replace(body, `reading = "READING.md"`, "source = \"../language\"\nreading = \"READING.md\"", 1)
|
||||
if err := os.WriteFile(filepath.Join(suiteRoot, "suite.toml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkClean(t, suiteRoot)
|
||||
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
if code, out := run(t, root, "", false, "add", "time"); code != cli.OK {
|
||||
t.Fatalf("convy add returned %d: %s", code, out)
|
||||
}
|
||||
if guide := read(t, root, "docs/conventions/READING.md"); !strings.Contains(guide, "ДОПУСКАЕТСЯ") {
|
||||
t.Errorf("the guide did not come from the level it lives on:\n%s", guide)
|
||||
}
|
||||
}
|
||||
|
||||
// The rules of the repository take a prefix on X and live below the marker. One
|
||||
// standing above it would be wiped by the next pull, and saying so afterwards
|
||||
// is too late.
|
||||
func TestCheckCatchesALocalRuleAboveTheMarker(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
run(t, root, "", false, "add", "time")
|
||||
|
||||
name := filepath.Join(root, "docs", "conventions", "time.md")
|
||||
body := read(t, root, "docs/conventions/time.md")
|
||||
local := "\n### XTIM-1. Часы в тестах замораживаются\n\n" +
|
||||
"**ДОЛЖЕН.** Тест берёт время у подменённого `store.Now`.\n\n" +
|
||||
"**ПОЧЕМУ.** Плавающее время делает падение теста невоспроизводимым.\n"
|
||||
|
||||
if err := os.WriteFile(name, []byte(body+local), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, out := run(t, root, "", false, "check"); code != cli.OK {
|
||||
t.Fatalf("a local rule below the marker was turned down: %d\n%s", code, out)
|
||||
}
|
||||
|
||||
above := strings.Replace(body, "<!-- conv:local -->", local+"\n<!-- conv:local -->", 1)
|
||||
if err := os.WriteFile(name, []byte(above), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, out := run(t, root, "", false, "check")
|
||||
if code == cli.OK {
|
||||
t.Fatalf("a rule of the repository above the marker went unnoticed:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "would wipe it") {
|
||||
t.Errorf("the finding does not say what is at stake:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/project"
|
||||
)
|
||||
|
||||
// convy pull reassembles what the manifest lists. It never reports what
|
||||
// changed: after a pull that is shown by git diff, and the decision to accept,
|
||||
// to fix or to roll back is taken by a person before the commit. A second
|
||||
// mechanism for comparing files, standing next to git, would answer the same
|
||||
// question worse.
|
||||
|
||||
func runPull(env Env, args []string) ExitCode {
|
||||
fs := flag.NewFlagSet("convy pull", flag.ContinueOnError)
|
||||
fs.SetOutput(env.Err)
|
||||
root := fs.String("root", "", "root of the project; it is looked up upwards by default")
|
||||
forComponent := fs.String("for", "", "component to reassemble; every one of them by default")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return Usage
|
||||
}
|
||||
|
||||
o, code := openProject(env, *root)
|
||||
if code != OK {
|
||||
return code
|
||||
}
|
||||
defer o.Close()
|
||||
|
||||
if err := distinctDirs(o.Manifest); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Usage
|
||||
}
|
||||
names, code := components(env, o.Manifest, *forComponent)
|
||||
if code != OK {
|
||||
return code
|
||||
}
|
||||
|
||||
failed := 0
|
||||
written := 0
|
||||
for i, name := range names {
|
||||
c := o.Manifest.Components[name]
|
||||
if i > 0 {
|
||||
fmt.Fprintln(env.Out)
|
||||
}
|
||||
fmt.Fprintf(env.Out, "%s → %s\n", name, c.Dir)
|
||||
if c.Dir == "" {
|
||||
fmt.Fprintln(env.Err, " the component names no dir, and a copy has to be written somewhere")
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
if len(c.Topics) == 0 {
|
||||
fmt.Fprintln(env.Out, " subscribed to nothing yet")
|
||||
}
|
||||
|
||||
// The path column is sized to the component rather than guessed: a
|
||||
// name that overruns a fixed width breaks every row below it.
|
||||
width := len(c.Dir) + 1 + len(project.ReadingName)
|
||||
for _, topic := range c.Topics {
|
||||
width = max(width, len(c.Dir)+len(topic)+4)
|
||||
}
|
||||
|
||||
for _, topic := range c.Topics {
|
||||
made, err := project.Assemble(o.Suite, o.Root, c, topic)
|
||||
if err != nil {
|
||||
fmt.Fprintf(env.Err, " %s: %s\n", topic, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
written++
|
||||
fmt.Fprintf(env.Out, " %-*s %s%s\n", width, made.Path, plural(len(made.Layers), "layer"), kept(made))
|
||||
}
|
||||
|
||||
guide, err := project.Reading(o.LangRoot, o.Suite.Manifest.Language.Reading, o.Root, c.Dir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(env.Err, " %s\n", err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(env.Out, " %-*s the guide to reading a rule\n", width, guide)
|
||||
}
|
||||
|
||||
fmt.Fprintf(env.Out, "\n%s assembled", plural(written, "file"))
|
||||
if failed > 0 {
|
||||
fmt.Fprintf(env.Out, ", %s\n", plural(failed, "failure"))
|
||||
return Failed
|
||||
}
|
||||
fmt.Fprintln(env.Out)
|
||||
fmt.Fprintln(env.Out, "git diff says what changed")
|
||||
return OK
|
||||
}
|
||||
|
||||
// kept notes that a local part was carried over, because that is the one thing
|
||||
// a reassembly could have destroyed and did not.
|
||||
func kept(c project.Copy) string {
|
||||
switch {
|
||||
case c.Created:
|
||||
return ", new"
|
||||
case c.Kept:
|
||||
return ", local part kept"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/doc"
|
||||
"git.vakhrushev.me/av/convy/internal/lang"
|
||||
@@ -22,7 +23,7 @@ func runSuiteList(env Env, args []string) ExitCode {
|
||||
root := fs.String("root", "", "root of the suite; by default it is looked up upwards")
|
||||
topic := fs.String("topic", "", "show one topic only")
|
||||
langAxis := fs.String("lang", "", "language of the component, to show what it would take")
|
||||
stackAxis := fs.String("stack", "", "stack of the component, to show what it would take")
|
||||
stackAxis := fs.String("stack", "", "stack of the component, comma-separated, to show what it would take")
|
||||
retired := fs.Bool("retired", false, "show the retired names instead of the live ones")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return Usage
|
||||
@@ -60,7 +61,7 @@ func runSuiteList(env Env, args []string) ExitCode {
|
||||
}
|
||||
|
||||
selecting := *langAxis != "" || *stackAxis != ""
|
||||
component := suite.Component{Lang: *langAxis, Stack: *stackAxis}
|
||||
component := suite.Component{Lang: split(*langAxis), Stack: split(*stackAxis)}
|
||||
for i, name := range topics {
|
||||
if i > 0 {
|
||||
fmt.Fprintln(env.Out)
|
||||
@@ -141,6 +142,19 @@ func listRetired(w io.Writer, s *suite.Suite) {
|
||||
fmt.Fprintln(w, "\nnone of these names is ever handed out again")
|
||||
}
|
||||
|
||||
// split reads a comma-separated list off the command line. An axis of a
|
||||
// component is a list — a component may sit on two stacks at once — and one
|
||||
// flag repeated is worse to type than one flag with commas in it.
|
||||
func split(value string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
|
||||
Reference in New Issue
Block a user