package cli_test import ( "encoding/json" "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 } // One directory inside another is the same collision told less plainly, and it // makes every command that walks a component find the copies of the other. func TestNestedComponentDirectoriesAreRefused(t *testing.T) { suiteRoot := subscribable(t) root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs", "--lang", "go") addComponent(t, root, "web", manifest.Component{Dir: "docs/web", Topics: []string{}}) for _, name := range []string{"sync", "pull", "check"} { t.Run(name, func(t *testing.T) { code, out := run(t, root, "", false, name) if code == cli.OK { t.Fatalf("nested directories went through:\n%s", out) } if !strings.Contains(out, "lies inside") { t.Errorf("the refusal does not say what is nested:\n%s", out) } }) } } // A copy may name a rule of a topic the component did not take: naming one // outside the norm is allowed, and the reader loses a pointer rather than a // requirement. It is still the one thing about a copy that cannot be seen // without the suite, so sync says it. func TestSyncNotesAReferenceToAnUnsubscribedTopic(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 += "\nПодробности — SLOG-1.\n" if err := os.WriteFile(name, []byte(body), 0o644); err != nil { t.Fatal(err) } code, out := run(t, root, "", false, "sync") if code != cli.OK { t.Fatalf("sync returned %d: %s", code, out) } if !strings.Contains(out, "SLOG-1") || !strings.Contains(out, "logging") { t.Errorf("the note does not name the reference and its topic:\n%s", out) } // Subscribed, it is no longer dangling. run(t, root, "", false, "add", "logging") _, out = run(t, root, "", false, "sync") if strings.Contains(out, "not taken by this component") { t.Errorf("the note stayed after the topic was taken:\n%s", out) } } // The findings a person reads and the findings a machine reads are the same // findings, in the same order. func TestCheckWritesJSONForACallerThatIsNotAPerson(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, "check", "--json") if code != cli.OK { t.Fatalf("convy check --json returned %d: %s", code, out) } var clean struct { Findings []map[string]any `json:"findings"` Errors int `json:"errors"` Warnings int `json:"warnings"` } if err := json.Unmarshal([]byte(out), &clean); err != nil { t.Fatalf("the output is not JSON: %v\n%s", err, out) } if len(clean.Findings) != 0 || clean.Errors != 0 { t.Errorf("a sound project reported findings: %s", out) } // Now break it and read the finding back. 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, "check", "--json") if code == cli.OK { t.Fatalf("the broken copy passed:\n%s", out) } if err := json.Unmarshal([]byte(out), &clean); err != nil { t.Fatalf("the output is not JSON: %v\n%s", err, out) } if clean.Errors != 1 || len(clean.Findings) != 1 { t.Fatalf("expected one error, got %s", out) } f := clean.Findings[0] for key, want := range map[string]any{"severity": "error", "family": "spread"} { if f[key] != want { t.Errorf("%s is %v, expected %v", key, f[key], want) } } if f["path"] != "docs/conventions/time.md" { t.Errorf("path is %v", f["path"]) } }