манифесты стали данными, заведён convy sync

- убраны комментарии из suite.toml и .conventions.toml: файл, который
  машина переписывает, комментарий через круг не проносит; объяснения
  ушли в README рядом, который suite init теперь заводит
- удалена текстовая правка манифеста целиком — 520 строк ручного
  лексера TOML вместе со всем классом ошибок порчи данных
- запись идёт из структур энкодером; ключ, которого инструмент не
  знает, запись останавливает, а не теряется молча
- convy sync сверяет манифест и подводит под него раскладку файлов:
  чего не хватает — собирает, что осиротело — удаляет, копию с
  локальной частью не трогает никогда
This commit is contained in:
av
2026-07-28 09:45:10 +03:00
parent b6b0976c19
commit 92bd1f463d
18 changed files with 851 additions and 1055 deletions
+2 -12
View File
@@ -3,7 +3,6 @@ package cli
import (
"flag"
"fmt"
"os"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
@@ -96,17 +95,8 @@ func runAdd(env Env, args []string) ExitCode {
// 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 {
o.Manifest.Subscribe(name, topic)
if err := o.Manifest.Save(); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
+4 -1
View File
@@ -55,6 +55,8 @@ func Run(env Env, args []string) ExitCode {
return runAdd(env, args[1:])
case "pull":
return runPull(env, args[1:])
case "sync":
return runSync(env, args[1:])
case "list":
return runList(env, args[1:])
case "check":
@@ -100,7 +102,8 @@ func usage(w io.Writer) {
In a project:
convy init wire up conventions: the source and the first component
convy add <topic> subscribe and assemble
convy pull reassemble what is subscribed
convy pull reassemble what is subscribed, text and all
convy sync make the files follow the manifest, and say what is off
convy list what is wired up and what else the suite has
convy check check the form of what is here
+18 -55
View File
@@ -5,42 +5,16 @@ 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"
)
// 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.
`
// A project manifest is data, and the tool rewrites it whole on every
// subscription. So it carries no comment: what a component is for is said in
// the documentation, which stays put, rather than in a file that a machine
// re-encodes behind the author's back.
func runInit(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy init", flag.ContinueOnError)
@@ -105,27 +79,25 @@ func runInit(env Env, args []string) ExitCode {
return Failed
}
entries := [][2]string{{"dir", manifest.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(fmt.Appendf(nil, projectSkeleton, manifest.Quote(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 {
m := &manifest.Project{
Source: given["source"],
Components: map[string]manifest.Component{
given["component"]: {
Dir: given["dir"],
Lang: split(given["lang"]),
Stack: split(given["stack"]),
Topics: []string{},
},
},
Path: name,
Root: where,
}
if err := m.Save(); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
@@ -181,12 +153,3 @@ func initFields() []Field {
Optional: true,
}}
}
// array writes a TOML array of strings.
func array(values []string) string {
parts := make([]string, len(values))
for i, v := range values {
parts[i] = manifest.Quote(v)
}
return "[" + strings.Join(parts, ", ") + "]"
}
+45 -20
View File
@@ -11,8 +11,22 @@ import (
"git.vakhrushev.me/av/convy/internal/check"
"git.vakhrushev.me/av/convy/internal/cli"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/manifest"
)
// addComponent puts one more component into the project manifest.
func addComponent(t *testing.T, root, name string, c manifest.Component) {
t.Helper()
m, err := manifest.LoadProject(root)
if err != nil {
t.Fatal(err)
}
m.Components[name] = c
if err := m.Save(); err != nil {
t.Fatal(err)
}
}
// 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 = `# Как читать конвенцию
@@ -43,21 +57,44 @@ func subscribable(t *testing.T) string {
**ДОЛЖЕН.** Текущее время приходит из `+"`store.Now()`"+`.
**ПОЧЕМУ.** Единая точка даёт гарантированный UTC и один формат.
`)
// A topic whose only layer sits on an axis: a component of another stack
// takes nothing of it at all. The canon has such topics, and a fixture
// where every topic has a base layer would never exercise that.
run(t, root, "", false, "suite", "add", "--topic", "web-ui", "--about", "веб-UI",
"--prefix", "HTMX", "--stack", "htmx", "--title", "Веб-UI на htmx")
appendRules(t, root, "conventions/stack/htmx/web-ui.md", `
### HTMX-1. Партиал отвечает фрагментом, а не страницей
**ДОЛЖЕН.** Обработчик свопа возвращает только заменяемый фрагмент.
**ПОЧЕМУ.** Страница целиком заставляет браузер выбросить состояние формы.
`)
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)
}
edit(t, root, func(m *manifest.Manifest) { m.Language.Reading = "READING.md" })
checkClean(t, root)
return root
}
// edit changes the suite manifest the way the tool does: through the struct,
// because the file is data and carries nothing else to preserve.
func edit(t *testing.T, root string, change func(*manifest.Manifest)) {
t.Helper()
m, err := manifest.Load(root)
if err != nil {
t.Fatal(err)
}
change(m)
if err := m.Save(); err != nil {
t.Fatal(err)
}
}
// wired builds a project taking from that suite, and returns its root.
func wired(t *testing.T, suiteRoot string, args ...string) string {
t.Helper()
@@ -260,11 +297,7 @@ 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)
}
addComponent(t, root, "web", manifest.Component{Dir: "web/docs", Topics: []string{}})
code, out := run(t, root, "", false, "add", "logging", "--for", "web")
if code != cli.OK {
@@ -285,11 +318,7 @@ 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)
}
addComponent(t, root, "web", manifest.Component{Dir: "docs/conventions", Topics: []string{}})
code, out := run(t, root, "", false, "pull")
if code == cli.OK {
@@ -340,11 +369,7 @@ func TestTheLanguageMayLiveApartFromTheSuite(t *testing.T) {
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)
}
edit(t, suiteRoot, func(m *manifest.Manifest) { m.Language.Source = "../language" })
checkClean(t, suiteRoot)
// A check that did not run says so: silence would read as a check passed.
+3 -16
View File
@@ -252,27 +252,14 @@ func writeConvention(env Env, s *suite.Suite, given map[string]string) ExitCode
return Usage
}
source, err := os.ReadFile(s.Manifest.Path)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
if !s.Manifest.TopicLive(given["topic"]) {
source, err = manifest.AddEntry(source, "topics.live", given["topic"], given["about"])
if err != nil {
fmt.Fprintf(env.Err, "%s was written, but the manifest was not: %s\n", rel, err)
return Failed
}
s.Manifest.Topics.Add(given["topic"], given["about"])
}
source, err = manifest.AddEntry(source, "prefixes.live", given["prefix"], rel)
if err != nil {
s.Manifest.Prefixes.Add(given["prefix"], rel)
if err := s.Manifest.Save(); err != nil {
fmt.Fprintf(env.Err, "%s was written, but the manifest was not: %s\n", rel, err)
return Failed
}
if err := os.WriteFile(s.Manifest.Path, source, 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
fmt.Fprintf(env.Out, "\ncreated %s\n", rel)
fmt.Fprintf(env.Out, "updated %s: prefix %s", manifest.Name, given["prefix"])
+59 -39
View File
@@ -10,52 +10,56 @@ import (
"git.vakhrushev.me/av/convy/internal/manifest"
)
// manifestSkeleton is what a suite starts as. The tables stand empty but
// present: `convy suite add` splices entries into them, and a table that is not
// there is a table an edit cannot find.
// 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 documents about the language are left commented out on purpose. The
// suite is expected to carry them, but the tool cannot author them, and a
// manifest pointing at a file that does not exist is a manifest that fails its
// own check on the first run.
const manifestSkeleton = `# The manifest of a conventions suite.
#
# Two manifests exist in the model, each named after what it describes:
# suite.toml here, in the suite, describes the suite itself; .conventions.toml
# in a project describes what that project subscribed to. Which of the two lies
# next to you tells you where you are.
// 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
[language]
version = %d
lang = "%s"
# description = "LANGUAGE.md" # the full account of the language, stays with the author
# reading = "READING.md" # the short guide for a reader, travels into every copy
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.
# ─── Topics ─────────────────────────────────────────────────────────────────
#
# A topic is a set of rules about one focus of development, and the unit of
# subscription. The value is the one line about what the topic is for; the
# table of conventions in a consumer's README is built out of it.
## The manifest
[topics.live]
` + "`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.
# Retired names land here together with a reason and a date, so that they can
# never be handed to another topic: the name lives on in foreign repositories.
## Topics
[topics.retired]
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.
# ─── Rule prefixes ──────────────────────────────────────────────────────────
#
# A prefix is four uppercase Latin letters, unique across the suite, chosen for
# a file rather than derived by a formula. The letter X is reserved for the
# local rules of consumers and is never taken here. Paths are given from the
# root of the repository.
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.live]
## Prefixes
# Prefixes of deleted and split files land here, likewise never to be reissued.
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.
[prefixes.retired]
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 {
@@ -108,17 +112,33 @@ func runSuiteInit(env Env, args []string) ExitCode {
return Usage
}
body := fmt.Sprintf(manifestSkeleton, *version, given["lang"])
name := filepath.Join(root, manifest.Name)
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
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
+4 -33
View File
@@ -12,7 +12,6 @@ import (
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/suite"
)
@@ -260,23 +259,9 @@ func retirePrefix(env Env, d *dialogue, s *suite.Suite, given map[string]string,
}
}
source, err := os.ReadFile(s.Manifest.Path)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
source, err = manifest.RemoveEntry(source, "prefixes.live", prefix)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
note := fmt.Sprintf("%s, was %s: %s", when, target.Path, given["reason"])
source, err = manifest.AddEntry(source, "prefixes.retired", prefix, note)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
if err := os.WriteFile(s.Manifest.Path, source, 0o644); err != nil {
s.Manifest.Prefixes.Retire(prefix, note)
if err := s.Manifest.Save(); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
@@ -314,22 +299,8 @@ func retireTopic(env Env, d *dialogue, s *suite.Suite, given map[string]string,
}
}
source, err := os.ReadFile(s.Manifest.Path)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
source, err = manifest.RemoveEntry(source, "topics.live", topic)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
source, err = manifest.AddEntry(source, "topics.retired", topic, when+": "+given["reason"])
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
if err := os.WriteFile(s.Manifest.Path, source, 0o644); err != nil {
s.Manifest.Topics.Retire(topic, when+": "+given["reason"])
if err := s.Manifest.Save(); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
+215
View File
@@ -0,0 +1,215 @@
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/project"
)
// convy sync makes the files agree with the manifest. The manifest is the
// truth: it says which components exist, where they write and what each takes,
// and everything under those directories follows from that.
//
// It divides from pull by what it is about. pull is about the contents of a
// copy — it takes the text of every subscription afresh, and the diff it leaves
// is the point of running it. sync is about the set of files: what the manifest
// calls for and is not there gets assembled, what is there and nothing calls
// for gets reported and, when nothing of the repository is in it, removed.
//
// A copy carrying a local part is never removed. Below the marker is the one
// thing in the directory that exists nowhere else, and a command that tidies up
// has no business deciding it is spent.
func runSync(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy sync", 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 bring in line; every one of them by default")
dry := fs.Bool("dry-run", false, "say what would change and change nothing")
if err := fs.Parse(args); err != nil {
return Usage
}
if code := noStrayArgs(env, "convy sync", fs.Args()); code != OK {
return code
}
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
}
problems := validateManifest(o, names)
for _, p := range problems {
fmt.Fprintf(env.Err, "%s\n", p)
}
if len(problems) > 0 {
fmt.Fprintln(env.Err, "\nthe manifest is what the layout follows, so nothing was touched")
return Failed
}
changed, left := 0, 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)
n, stuck, code := syncComponent(env, o, c, *dry)
if code != OK {
return code
}
changed += n
left += stuck
}
fmt.Fprintln(env.Out)
switch {
case *dry && changed > 0:
fmt.Fprintf(env.Out, "%s would change; run without --dry-run to do it\n", plural(changed, "file"))
case changed > 0:
fmt.Fprintf(env.Out, "%s changed; convy pull takes the text of the rest afresh\n", plural(changed, "file"))
case left == 0:
fmt.Fprintln(env.Out, "the layout already follows the manifest")
}
if left > 0 {
fmt.Fprintf(env.Out, "%s left alone: nothing subscribes to it and it holds a local part\n", plural(left, "file"))
return Failed
}
return OK
}
// validate checks the manifest against itself and against the suite. Everything
// wrong is reported at once: being sent back one line at a time is the worst way
// to learn what a file wants.
func validateManifest(o *opened, names []string) []string {
var out []string
if err := distinctDirs(o.Manifest); err != nil {
out = append(out, err.Error())
}
for _, name := range names {
c := o.Manifest.Components[name]
if c.Dir == "" {
out = append(out, fmt.Sprintf("the component %q names no dir, and a copy has to be written somewhere", name))
}
if len(c.Lang) > 1 {
out = append(out, fmt.Sprintf("the component %q declares two languages (%s): a line of code is written in one of them, and a component is the region where every chosen layer holds at once — split it",
name, strings.Join(c.Lang, ", ")))
}
seen := make(map[string]bool, len(c.Topics))
for _, topic := range c.Topics {
switch {
case seen[topic]:
out = append(out, fmt.Sprintf("the component %q takes %q twice", name, topic))
case o.Suite.Manifest.TopicRetired(topic):
out = append(out, fmt.Sprintf("the component %q takes %q, which the suite has retired: %s",
name, topic, o.Suite.Manifest.Topics.Retired[topic]))
case !o.Suite.Manifest.TopicLive(topic):
out = append(out, fmt.Sprintf("the component %q takes %q, and the suite declares no such topic", name, topic))
default:
if taken, _ := o.Suite.Assemble(topic, project.Axis(c)); len(taken) == 0 {
out = append(out, fmt.Sprintf("the component %q takes %q, and no layer of it fits this component", name, topic))
}
}
seen[topic] = true
}
}
return out
}
// syncComponent brings one directory in line. It returns how many files moved
// and how many it would not touch.
func syncComponent(env Env, o *opened, c manifest.Component, dry bool) (changed, left int, code ExitCode) {
for _, topic := range c.Topics {
rel := filepath.ToSlash(filepath.Join(c.Dir, topic+".md"))
if exists(filepath.Join(o.Root, filepath.FromSlash(rel))) {
continue
}
changed++
if dry {
fmt.Fprintf(env.Out, " + %-28s subscribed, and no file\n", rel)
continue
}
made, err := project.Assemble(o.Suite, o.Root, c, topic)
if err != nil {
fmt.Fprintf(env.Err, " %s: %s\n", topic, err)
return changed, left, Failed
}
fmt.Fprintf(env.Out, " + %-28s %s\n", made.Path, plural(len(made.Layers), "layer"))
}
orphans, err := orphaned(o.Root, c)
if err != nil {
fmt.Fprintln(env.Err, err)
return changed, left, Failed
}
for _, orphan := range orphans {
switch {
case orphan.local:
left++
fmt.Fprintf(env.Out, " ! %-28s nothing subscribes to %q, and it carries a local part: remove it by hand or subscribe again\n",
orphan.path, orphan.topic)
case dry:
changed++
fmt.Fprintf(env.Out, " - %-28s nothing subscribes to %q\n", orphan.path, orphan.topic)
default:
if err := os.Remove(filepath.Join(o.Root, filepath.FromSlash(orphan.path))); err != nil {
fmt.Fprintln(env.Err, err)
return changed, left, Failed
}
changed++
fmt.Fprintf(env.Out, " - %-28s nothing subscribes to %q\n", orphan.path, orphan.topic)
}
}
if !dry {
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)
return changed, left, Failed
}
fmt.Fprintf(env.Out, " = %-28s the guide to reading a rule\n", guide)
}
return changed, left, OK
}
// orphan is a copy in a component directory that the manifest does not call for.
type orphan struct {
path string
topic string
local bool
}
// orphaned finds the copies nothing subscribes to. What is a copy is decided by
// the origin key: README.md belongs to the repository, READING.md belongs to the
// suite, and a file whose origin was taken away has become a document of the
// repository — none of the three is anyone's to remove.
func orphaned(root string, c manifest.Component) ([]orphan, error) {
docs, broken := copies(root, c.Dir)
if len(broken) > 0 {
return nil, broken[0]
}
var out []orphan
for _, d := range docs {
if c.Subscribed(d.Front.Origin) {
continue
}
local := ""
if at := d.Marker(); at > 0 {
local = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(d.Below(at)), project.LocalMarker))
}
out = append(out, orphan{path: d.Path, topic: d.Front.Origin, local: local != ""})
}
return out, nil
}
+166
View File
@@ -0,0 +1,166 @@
package cli_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/cli"
"git.vakhrushev.me/av/convy/internal/manifest"
)
// subscribe edits the project manifest the way a person would: by hand, in the
// file. That is the whole premise of sync — the manifest is the truth, and the
// layout follows it.
func subscribe(t *testing.T, root string, topics ...string) {
t.Helper()
m, err := manifest.LoadProject(root)
if err != nil {
t.Fatal(err)
}
c := m.Components["backend"]
c.Topics = topics
m.Components["backend"] = c
if err := m.Save(); err != nil {
t.Fatal(err)
}
}
func TestSyncAssemblesWhatIsMissingAndRemovesWhatIsOrphaned(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
run(t, root, "", false, "add", "time")
// The manifest is edited by hand: time goes, logging comes.
subscribe(t, root, "logging")
code, out := run(t, root, "", false, "sync", "--dry-run")
if code != cli.OK {
t.Fatalf("the dry run returned %d: %s", code, out)
}
if !strings.Contains(out, "would change") {
t.Errorf("the dry run promised nothing:\n%s", out)
}
if !exists(t, root, "docs/conventions/time.md") {
t.Errorf("the dry run removed a file")
}
if exists(t, root, "docs/conventions/logging.md") {
t.Errorf("the dry run assembled a file")
}
code, out = run(t, root, "", false, "sync")
if code != cli.OK {
t.Fatalf("sync returned %d: %s", code, out)
}
if !exists(t, root, "docs/conventions/logging.md") {
t.Errorf("the subscribed topic was not assembled:\n%s", out)
}
if exists(t, root, "docs/conventions/time.md") {
t.Errorf("the copy nothing subscribes to stayed:\n%s", out)
}
// Run again: nothing left to do, and it says so.
code, out = run(t, root, "", false, "sync")
if code != cli.OK || !strings.Contains(out, "already follows the manifest") {
t.Errorf("a second sync found work to do:\n%s", out)
}
}
// Below the marker is the one thing in the directory that exists nowhere else.
func TestSyncLeavesAnOrphanCarryingALocalPart(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 += "\nTIME-1 — МЕХАНИЗИРОВАНО: `internal/archrules`.\n"
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
subscribe(t, root)
code, out := run(t, root, "", false, "sync")
if code == cli.OK {
t.Fatalf("an orphan with a local part went unremarked:\n%s", out)
}
if !strings.Contains(out, "local part") {
t.Errorf("the report does not say why the file was left:\n%s", out)
}
if !exists(t, root, "docs/conventions/time.md") {
t.Fatalf("the local part was destroyed:\n%s", out)
}
}
// The manifest is the truth, so a manifest that does not hold together stops
// the command before anything is written.
func TestSyncValidatesTheManifestBeforeTouchingAnything(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
cases := []struct {
name string
change func(*manifest.Project)
want string
}{{
name: "a topic the suite does not declare",
change: func(m *manifest.Project) { subscribeTo(m, "billing") },
want: "no such topic",
}, {
name: "the same topic twice",
change: func(m *manifest.Project) { subscribeTo(m, "time", "time") },
want: "twice",
}, {
name: "two languages in one component",
change: func(m *manifest.Project) {
c := m.Components["backend"]
c.Lang = []string{"go", "javascript"}
m.Components["backend"] = c
},
want: "declares two languages",
}, {
name: "a topic no layer of which fits",
change: func(m *manifest.Project) {
// web-ui lives on the htmx stack only, and this component is on
// no stack at all.
subscribeTo(m, "web-ui")
},
want: "no layer of it fits",
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m, err := manifest.LoadProject(root)
if err != nil {
t.Fatal(err)
}
tc.change(m)
if err := m.Save(); err != nil {
t.Fatal(err)
}
code, out := run(t, root, "", false, "sync")
if code == cli.OK {
t.Fatalf("the manifest went through:\n%s", out)
}
if !strings.Contains(out, tc.want) {
t.Errorf("the report does not say %q:\n%s", tc.want, out)
}
if !strings.Contains(out, "nothing was touched") {
t.Errorf("the report does not say it wrote nothing:\n%s", out)
}
})
}
}
func subscribeTo(m *manifest.Project, topics ...string) {
c := m.Components["backend"]
c.Topics = topics
m.Components["backend"] = c
}
func exists(t *testing.T, parts ...string) bool {
t.Helper()
_, err := os.Stat(filepath.Join(parts...))
return err == nil
}