package cli_test import ( "fmt" "os" "path" "path/filepath" "strings" "testing" "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 = `# Как читать конвенцию Слова толкуются так, и только когда написаны заглавными. | Слово | Значение | |---|---| | ДОЛЖЕН | требование | | НЕ ДОЛЖЕН | запрет | | СЛЕДУЕТ | рекомендация | | НЕ СЛЕДУЕТ | рекомендация против | | ДОПУСКАЕТСЯ | разрешение | | ПОЧЕМУ | обоснование | | ПРИМЕРЫ | иллюстрация | | МЕХАНИЗИРОВАНО | чем проверяется | | СНЯТО | заглушка снятого правила | ` // 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 и один формат. `) // 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) } 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() 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.", "", } { 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") 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 { 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") addComponent(t, root, "web", manifest.Component{Dir: "docs/conventions", Topics: []string{}}) 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) } 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. if _, out := run(t, suiteRoot, "", false, "suite", "check"); !strings.Contains(out, "went unchecked") { t.Errorf("the check did not say it left the language alone:\n%s", out) } 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, "", local+"\n", 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) } } // copyClean is the project half of the invariant worth the most: what the tool // writes, the tool accepts — and accepts in silence, warnings included. func copyClean(t *testing.T, root, dir string) { t.Helper() var docs []*doc.Document entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(dir))) if err != nil { t.Fatal(err) } for _, e := range entries { if filepath.Ext(e.Name()) != ".md" { continue } rel := path.Join(dir, e.Name()) d, err := doc.Load(rel, filepath.Join(root, filepath.FromSlash(rel))) if err != nil { t.Fatalf("loading %s: %v", rel, err) } if d.Front.Origin != "" { docs = append(docs, d) } } if len(docs) == 0 { t.Fatalf("no copy was assembled in %s", dir) } rep := check.Copies(docs) if len(rep.Findings()) == 0 { return } var b strings.Builder for _, f := range rep.Findings() { fmt.Fprintf(&b, " %s: %s\n", f.Path, f.Msg) } t.Fatalf("the copies the tool assembled do not check clean:\n%s", b.String()) } func TestWhatTheToolAssemblesTheToolAccepts(t *testing.T) { suiteRoot := subscribable(t) root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go") run(t, root, "", false, "add", "time") run(t, root, "", false, "add", "logging") copyClean(t, root, "docs/conventions") } // The flag package stops at the first argument that is not a flag, so a stray // one hides every flag written after it and the command quietly does something // else than what was asked. func TestProjectCommandsTurnDownAStrayArgument(t *testing.T) { suiteRoot := subscribable(t) root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go") for _, args := range [][]string{ {"pull", "backend"}, {"list", "backend"}, {"check", "backend"}, {"init", "somewhere"}, } { t.Run(args[0], func(t *testing.T) { code, out := run(t, root, "", false, args...) if code == cli.OK { t.Fatalf("the stray argument went through:\n%s", out) } if !strings.Contains(out, "takes no argument") { t.Errorf("the refusal does not say what is wrong:\n%s", out) } }) } } // The marker is always left by the assembler, so a copy without one was edited // by hand — and everything in it would count as suite text to be replaced. func TestPullRefusesACopyWhoseMarkerIsGone(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) 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 went ahead over a hand-edited copy:\n%s", out) } if !strings.Contains(out, "carries no marker") { t.Errorf("the refusal does not say why:\n%s", out) } if got := read(t, root, "docs/conventions/time.md"); !strings.Contains(got, "Заметка, написанная руками") { t.Errorf("the hand-written text was destroyed anyway:\n%s", got) } } // Whatever the tool writes into the manifest it has to read back, and a // backslash is the ordinary way that fails. func TestInitWritesAManifestItCanRead(t *testing.T) { suiteRoot := subscribable(t) root := wired(t, suiteRoot, "--component", "backend", "--dir", `docs\conventions`) code, out := run(t, root, "", false, "list") if code != cli.OK { t.Fatalf("the manifest the tool wrote does not parse: %d\n%s", code, out) } if !strings.Contains(out, `docs\conventions`) { t.Errorf("the directory came back changed:\n%s", out) } } // A typo in a key costs a whole component, and the parser knows about it — so // every command that reads the manifest has to pass that on. func TestCheckSaysWhatItDidNotUnderstand(t *testing.T) { suiteRoot := subscribable(t) root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go") body := read(t, root, ".conventions.toml") body = strings.Replace(body, `dir = "docs/conventions"`, `dyr = "docs/conventions"`, 1) if err := os.WriteFile(filepath.Join(root, ".conventions.toml"), []byte(body), 0o644); err != nil { t.Fatal(err) } code, out := run(t, root, "", false, "check") if !strings.Contains(out, "dyr") { t.Errorf("the typo in the manifest went unmentioned:\n%s", out) } if code == cli.OK { t.Errorf("a component with no dir was checked anyway:\n%s", out) } }