package tools import ( "errors" "os" "path/filepath" "strings" "testing" "rickub.com/turbo-editors/turbo-core/profile" ) // testProfile is a fictional editor. The template it carries is a small but // real tools file, because Create's job is to write the profile's template and // a test asserting on what is in it would only be reading its own fixture back. func testProfile() profile.Profile { return profile.Profile{ Name: "Turbo Test", Slug: "turbo-test", Language: "Test", ToolsMenu: "~T~est", Templates: profile.Templates{Tools: testToolsTemplate}, } } const testToolsTemplate = `# turbo-test tools. [[tool]] name = "~B~uild" command = "make build" output = "popup" [[tool]] name = "~R~un" command = "make run" output = "terminal" ` // menu is the plain name of the toolchain menu the test profile uses. var menu = DefaultMenuName(testProfile()) // project builds a project directory holding a tools file. func project(t *testing.T, contents string) string { t.Helper() dir := t.TempDir() if contents == "" { return dir } if err := os.MkdirAll(filepath.Dir(Path(testProfile(), dir)), 0o755); err != nil { t.Fatalf("creating the tools directory: %v", err) } if err := os.WriteFile(Path(testProfile(), dir), []byte(contents), 0o644); err != nil { t.Fatalf("writing the tools file: %v", err) } return dir } // load reads a project's tools, failing the test if it cannot. func load(t *testing.T, dir string) List { t.Helper() list, err := Load(testProfile(), dir) if err != nil { t.Fatalf("Load(testProfile(), %q) error = %v", dir, err) } return list } // summary renders the tools as "name=command" strings, in order. func summary(list List) []string { out := make([]string, 0, list.Len()) for _, tool := range list.Tools() { out = append(out, tool.Name+"="+tool.Command) } return out } func TestLoadReadsTheToolsInFileOrder(t *testing.T) { // The menu should match the file, so someone reordering the file sees the // menu reorder. dir := project(t, ` [[tool]] name = "Test" command = "go test ./..." [[tool]] name = "Build" command = "go build ./..." `) got := summary(load(t, dir)) want := []string{"Test=go test ./...", "Build=go build ./..."} if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { t.Errorf("got %v, want %v", got, want) } } func TestAProjectWithNoToolsFileIsNotAnError(t *testing.T) { list := load(t, t.TempDir()) if list.Len() != 0 { t.Errorf("Len() = %d, want 0", list.Len()) } if got := list.Tools(); got != nil { t.Errorf("Tools() = %v, want nothing", got) } } func TestAFileThatIsNotTOMLIsReported(t *testing.T) { // A typo must be reported rather than silently leaving the menu empty. dir := project(t, "[[tool]\nname = ") _, err := Load(testProfile(), dir) if err == nil { t.Fatal("Load() accepted a file that is not TOML") } if !strings.Contains(err.Error(), Path(testProfile(), dir)) { t.Errorf("the error does not name the file: %v", err) } } func TestAToolWithNoNameOrNoCommandIsRefused(t *testing.T) { for name, contents := range map[string]string{ "no name": "[[tool]]\ncommand = \"go build\"\n", "no command": "[[tool]]\nname = \"Build\"\n", } { t.Run(name, func(t *testing.T) { if _, err := Load(testProfile(), project(t, contents)); err == nil { t.Errorf("Load() accepted a tool with %s", name) } }) } } func TestACommandMayBeAWholeSequence(t *testing.T) { // It goes to sh -c, so one entry can be several commands. dir := project(t, "[[tool]]\nname = \"Check\"\ncommand = \"go vet ./... && go test ./...\"\n") list := load(t, dir) if list.Len() != 1 { t.Fatalf("Len() = %d", list.Len()) } if got := list.Tools()[0].Command; got != "go vet ./... && go test ./..." { t.Errorf("Command = %q, want the sequence unchanged", got) } } func TestADirectoryWhereTheFileGoesCountsAsAbsent(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(Path(testProfile(), dir), 0o755); err != nil { t.Fatalf("creating a directory where the file goes: %v", err) } if Exists(testProfile(), dir) { t.Error("Exists() called a directory a tools file") } } func TestPathIsUnderTheEditorsOwnDirectory(t *testing.T) { // The directory is the editor's, so two editors in one repository keep // their tools apart rather than fighting over one file. want := filepath.Join("/src/p", ".turbo-test", "tools.toml") if got := Path(testProfile(), "/src/p"); got != want { t.Errorf("Path() = %q, want %q", got, want) } } func TestCreateWritesAFileThatLoadsBack(t *testing.T) { dir := t.TempDir() path, err := Create(testProfile(), dir) if err != nil { t.Fatalf("Create() error = %v", err) } if path != Path(testProfile(), dir) { t.Errorf("Create() returned %q, want %q", path, Path(testProfile(), dir)) } if !Exists(testProfile(), dir) { t.Fatal("Create() reported success but wrote no file") } if load(t, dir).Len() == 0 { t.Error("the created file holds no tools") } } func TestCreateRefusesToOverwriteAnExistingFile(t *testing.T) { original := "[[tool]]\nname = \"Mine\"\ncommand = \"make\"\n" dir := project(t, original) _, err := Create(testProfile(), dir) if !errors.Is(err, ErrExists) { t.Fatalf("Create() error = %v, want ErrExists", err) } if got := readFile(t, Path(testProfile(), dir)); got != original { t.Errorf("the existing file was changed:\n%s", got) } } // plain strips the tilde hot-key markers from a label. func plain(label string) string { return strings.ReplaceAll(label, "~", "") } // hotKey returns the character between the tildes, or 0 when there is none. func hotKey(label string) rune { first := strings.IndexByte(label, '~') if first < 0 || first+1 >= len(label) { return 0 } return rune(label[first+1]) } // readFile returns a file's contents. func readFile(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("reading %s: %v", path, err) } return string(data) } func TestAToolWithNoOutputDefaultsToAPopup(t *testing.T) { dir := project(t, "[[tool]]\nname = \"Test\"\ncommand = \"go test ./...\"\n") tool := load(t, dir).Tools()[0] if tool.Output != "" { t.Errorf("Output = %q, want it left empty in the file", tool.Output) } if got := tool.Where(); got != OutputPopup { t.Errorf("Where() = %q, want %q", got, OutputPopup) } } func TestEveryOutputTheFileMayNameIsAccepted(t *testing.T) { for _, output := range outputs { t.Run(string(output), func(t *testing.T) { dir := project(t, "[[tool]]\nname = \"X\"\ncommand = \"true\"\noutput = \""+string(output)+"\"\n") if got := load(t, dir).Tools()[0].Where(); got != output { t.Errorf("Where() = %q, want %q", got, output) } }) } } func TestAnUnknownOutputIsRefusedRatherThanCorrected(t *testing.T) { // Silently falling back would send the output somewhere the file did not // ask for, and "termnial" would look like it worked. dir := project(t, "[[tool]]\nname = \"Test\"\ncommand = \"true\"\noutput = \"termnial\"\n") _, err := Load(testProfile(), dir) if err == nil { t.Fatal("Load() accepted an output nobody defined") } for _, want := range []string{"Test", "termnial", "popup", "terminal", "editor"} { if !strings.Contains(err.Error(), want) { t.Errorf("the error never mentions %q: %v", want, err) } } } func TestAToolWithNoMenuGoesIntoTheEditorsToolchainMenu(t *testing.T) { dir := project(t, "[[tool]]\nname = \"Test\"\ncommand = \"go test ./...\"\n") if got := load(t, dir).Tools()[0].Menu; got != menu { t.Errorf("MenuName() = %q, want %q", got, menu) } } func TestAToolCanNameItsOwnMenu(t *testing.T) { dir := project(t, "[[tool]]\nname = \"Echo\"\ncommand = \"echo x\"\nmenu = \"Tools\"\n") if got := load(t, dir).Tools()[0].Menu; got != "Tools" { t.Errorf("MenuName() = %q, want Tools", got) } } func TestMenuNamesStartWithTheToolchainMenuAndFollowTheFile(t *testing.T) { // Go is always first, whether or not a tool named it: the item that creates // the tools file has to live somewhere even when there is no file. dir := project(t, ` [[tool]] name = "Up" command = "docker compose up" menu = "Docker" [[tool]] name = "Echo" command = "echo x" menu = "Tools" [[tool]] name = "Down" command = "docker compose down" menu = "Docker" `) got := load(t, dir).MenuNames() want := []string{menu, "Docker", "Tools"} if len(got) != len(want) { t.Fatalf("MenuNames() = %v, want %v", got, want) } for i := range want { if got[i] != want[i] { t.Errorf("menu %d = %q, want %q", i, got[i], want[i]) } } } func TestTheToolchainMenuIsTheOnlyOneWhenNothingNamesAnother(t *testing.T) { dir := project(t, "[[tool]]\nname = \"Test\"\ncommand = \"go test\"\n") if got := load(t, dir).MenuNames(); len(got) != 1 || got[0] != menu { t.Errorf("MenuNames() = %v, want just %q", got, menu) } } func TestTheToolchainMenuExistsEvenWithNoToolsAtAll(t *testing.T) { // A project with no tools file still gets the menu, because that is where // the item creating one lives. list, err := Load(testProfile(), t.TempDir()) if err != nil { t.Fatalf("Load() error = %v", err) } if got := list.MenuNames(); len(got) != 1 || got[0] != menu { t.Errorf("MenuNames() = %v, want just %q", got, menu) } } func TestInReturnsOneMenusToolsInFileOrder(t *testing.T) { dir := project(t, ` [[tool]] name = "a" command = "true" menu = "Tools" [[tool]] name = "b" command = "true" [[tool]] name = "c" command = "true" menu = "Tools" `) list := load(t, dir) mine := list.In("Tools") if len(mine) != 2 || mine[0].Name != "a" || mine[1].Name != "c" { t.Errorf("In(\"Tools\") = %v", mine) } if goTools := list.In(menu); len(goTools) != 1 || goTools[0].Name != "b" { t.Errorf("In(%q) = %v", menu, goTools) } if none := list.In("Nowhere"); none != nil { t.Errorf("In(\"Nowhere\") = %v, want nothing", none) } } func TestCreateWritesTheProfilesTemplateVerbatim(t *testing.T) { // The commands themselves belong to the editor, not to this package: what // is checked here is that the file written is the one the profile carries, // byte for byte. What is *in* Turbo Go's template is Turbo Go's own test. dir := t.TempDir() if _, err := Create(testProfile(), dir); err != nil { t.Fatalf("Create() error = %v", err) } if got := readFile(t, Path(testProfile(), dir)); got != testToolsTemplate { t.Errorf("Create() wrote:\n%s\nwant the profile's template:\n%s", got, testToolsTemplate) } } func TestDefaultMenuNameStripsTheHotKeyMarkers(t *testing.T) { // A tools file writes the plain name; the hot key is the editor's to // assign, because only it knows which letters the other menus have taken. tests := map[string]string{"~G~o": "Go", "Rus~t~": "Rust", "Zig": "Zig"} for label, want := range tests { if got := DefaultMenuName(profile.Profile{ToolsMenu: label}); got != want { t.Errorf("DefaultMenuName(%q) = %q, want %q", label, got, want) } } }