package cli_test import ( "os" "path/filepath" "strings" "testing" "git.vakhrushev.me/av/convy/internal/cli" ) // retirable builds a suite with two topics, three conventions and a couple of // rules, which is the least that lets every case of retirement be told apart. func retirable(t *testing.T) string { t.Helper() root := filepath.Join(t.TempDir(), "suite") run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru") run(t, root, "", false, "suite", "add", "--topic", "time", "--about", "время", "--prefix", "TIME", "--title", "Время") run(t, root, "", false, "suite", "add", "--topic", "time", "--prefix", "GTIM", "--lang", "go", "--title", "Время на Go") run(t, root, "", false, "suite", "add", "--topic", "logging", "--about", "логирование", "--prefix", "SLOG", "--title", "Логирование") appendRules(t, root, "conventions/time.md", ` ### TIME-1. Момент записывается в UTC **ДОЛЖЕН.** Момент времени записывается с суффиксом Z. **ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана. ### TIME-2. Ширина строки фиксируется **ДОЛЖЕН.** Внутри одной колонки длина строки времени одна. **ПОЧЕМУ.** Лексикографическая сортировка совпадает с хронологией только среди строк одинаковой длины. `) appendRules(t, root, "conventions/logging.md", ` ### SLOG-1. Уровень выбирается по адресату **ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение». **ПОЧЕМУ.** Адресат — единственный воспроизводимый признак. `) return root } func appendRules(t *testing.T, root, rel, rules string) { t.Helper() name := filepath.Join(root, filepath.FromSlash(rel)) body, err := os.ReadFile(name) if err != nil { t.Fatal(err) } if err := os.WriteFile(name, append(body, []byte("\n## Правила\n"+rules)...), 0o644); err != nil { t.Fatal(err) } } func TestRetireRuleLeavesAStub(t *testing.T) { root := retirable(t) code, out := run(t, root, "", false, "suite", "retire", "--rule", "TIME-2", "--reason", "ширина следует из TIME-1 и отдельного правила не требует", "--date", "2026-07-27") if code != cli.OK { t.Fatalf("retiring a rule returned %d: %s", code, out) } body := read(t, root, "conventions/time.md") if !strings.Contains(body, "### TIME-2. Ширина строки фиксируется") { t.Errorf("the heading and the number did not survive:\n%s", body) } if !strings.Contains(body, "**СНЯТО 2026-07-27.** ширина следует из TIME-1") { t.Errorf("the stub is not there or is worded wrong:\n%s", body) } if strings.Contains(body, "Лексикографическая сортировка") { t.Errorf("the rationale of the retired rule survived:\n%s", body) } if strings.Contains(body, "**ДОЛЖЕН.** Внутри одной колонки") { t.Errorf("the norm of the retired rule survived:\n%s", body) } // The numbering stays contiguous and the whole suite still checks clean — // which is the point of a stub over a deletion. checkClean(t, root) } func TestRetireRuleRefusesWhatItCannotDo(t *testing.T) { root := retirable(t) run(t, root, "", false, "suite", "retire", "--rule", "TIME-2", "--reason", "уже снято", "--date", "2026-07-27") cases := []struct{ name, id, want string }{ {"a rule already retired", "TIME-2", "retired already"}, {"a number no rule carries", "TIME-9", "holds no rule numbered 9"}, {"a prefix the suite does not declare", "ZZZZ-1", "no live prefix ZZZZ"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { code, out := run(t, root, "", false, "suite", "retire", "--rule", tc.id, "--reason", "почему-то") 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 takes its rules with it, and nothing stands in for a file the way a // stub stands in for a rule. So a convention still pointed at cannot go. func TestRetirePrefixRefusesWhileStillPointedAt(t *testing.T) { root := retirable(t) name := filepath.Join(root, "conventions", "logging.md") body, _ := os.ReadFile(name) if err := os.WriteFile(name, append(body, []byte("\nВремя в записи следует TIME-1.\n")...), 0o644); err != nil { t.Fatal(err) } code, out := run(t, root, "", false, "suite", "retire", "--prefix", "TIME", "--reason", "тема закрыта") if code == cli.OK { t.Fatalf("a convention still pointed at was retired:\n%s", out) } for _, want := range []string{"still pointed at", "conventions/logging.md", "TIME-1"} { if !strings.Contains(out, want) { t.Errorf("the refusal does not say %q:\n%s", want, out) } } if _, err := os.Stat(filepath.Join(root, "conventions", "time.md")); err != nil { t.Error("the file was removed despite the refusal") } } func TestRetirePrefixMovesTheNameAndTakesTheFile(t *testing.T) { root := retirable(t) code, out := run(t, root, "", false, "suite", "retire", "--prefix", "SLOG", "--reason", "тема свёрнута", "--date", "2026-07-27") if code != cli.OK { t.Fatalf("retiring a convention returned %d: %s", code, out) } if _, err := os.Stat(filepath.Join(root, "conventions", "logging.md")); !os.IsNotExist(err) { t.Error("the file stayed behind") } toml := read(t, root, ".conventions-suite.toml") if strings.Contains(toml, `SLOG = "conventions/logging.md"`) { t.Errorf("the prefix stayed in the live half:\n%s", toml) } if !strings.Contains(toml, `SLOG = "2026-07-27, was conventions/logging.md: тема свёрнута"`) { t.Errorf("the retired half does not carry the date, the path and the reason:\n%s", toml) } if !strings.Contains(out, "no layer left") { t.Errorf("the report does not point out that the topic is now empty:\n%s", out) } } // A topic lives as long as one layer of it does, so the name cannot go first. func TestRetireTopicWaitsForItsLayers(t *testing.T) { root := retirable(t) code, out := run(t, root, "", false, "suite", "retire", "--topic", "time", "--reason", "не нужна") if code == cli.OK { t.Fatalf("a topic with layers was retired:\n%s", out) } for _, want := range []string{"still holds layers", "conventions/time.md", "conventions/lang/go/time.md"} { if !strings.Contains(out, want) { t.Errorf("the refusal does not say %q:\n%s", want, out) } } run(t, root, "", false, "suite", "retire", "--prefix", "GTIM", "--reason", "слой снят", "--date", "2026-07-27") run(t, root, "", false, "suite", "retire", "--prefix", "TIME", "--reason", "слой снят", "--date", "2026-07-27") code, out = run(t, root, "", false, "suite", "retire", "--topic", "time", "--reason", "решение переехало в logging", "--date", "2026-07-27") if code != cli.OK { t.Fatalf("retiring an empty topic returned %d: %s", code, out) } toml := read(t, root, ".conventions-suite.toml") if strings.Contains(toml, `time = "время"`) { t.Errorf("the topic stayed in the live half:\n%s", toml) } if !strings.Contains(toml, `time = "2026-07-27: решение переехало в logging"`) { t.Errorf("the retired half does not carry the date and the reason:\n%s", toml) } checkClean(t, root) } func TestRetireNeedsExactlyOneTarget(t *testing.T) { root := retirable(t) code, out := run(t, root, "", false, "suite", "retire", "--rule", "TIME-1", "--topic", "time", "--reason", "и то и другое") if code == cli.OK { t.Fatalf("two targets at once went through:\n%s", out) } if !strings.Contains(out, "exactly one") { t.Errorf("the refusal does not say why:\n%s", out) } } // Retirement is irreversible, so the dialogue asks before writing and takes // silence for no. func TestRetireInteractiveConfirms(t *testing.T) { root := retirable(t) code, out := run(t, root, "rule\nTIME-2\nбольше не нужно\n\n", true, "suite", "retire") if code == cli.OK { t.Fatalf("an unconfirmed retirement went through:\n%s", out) } if !strings.Contains(out, "nothing was written") { t.Errorf("the report does not say that nothing happened:\n%s", out) } if strings.Contains(read(t, root, "conventions/time.md"), "**СНЯТО ") { t.Error("the file was written despite no confirmation") } code, out = run(t, root, "rule\nTIME-2\nбольше не нужно\ny\n", true, "suite", "retire") if code != cli.OK { t.Fatalf("a confirmed retirement returned %d: %s", code, out) } if !strings.Contains(read(t, root, "conventions/time.md"), "**СНЯТО ") { t.Error("a confirmed retirement wrote nothing") } }