diff --git a/cmd/internal/run/run.go b/cmd/internal/run/run.go index 2d7ba52d8..28aaa5b5e 100644 --- a/cmd/internal/run/run.go +++ b/cmd/internal/run/run.go @@ -14,7 +14,7 @@ * limitations under the License. */ -// Package run implements the “gop run” command. +// Package run implements the "gop run" command. package run import ( @@ -79,6 +79,13 @@ func runCmd(cmd *base.Command, args []string) { panic("TODO: profile not impl") } + if handled, err := runWithConfiguredRunner(proj, args, "."); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } else if handled { + return + } + noChdir := *flagNoChdir conf, err := tool.NewDefaultConf(".", tool.ConfFlagNoTestFiles, pass.Tags()) if err != nil { diff --git a/cmd/internal/run/runner.go b/cmd/internal/run/runner.go new file mode 100644 index 000000000..9f148c5e5 --- /dev/null +++ b/cmd/internal/run/runner.go @@ -0,0 +1,26 @@ +package run + +import "github.com/goplus/xgo/x/xgoprojs" + +func runWithConfiguredRunner(proj xgoprojs.Proj, args []string, workDir string) (bool, error) { + projectDirectory, err := resolveProjectDir(proj, workDir) + if err != nil { + return false, err + } + + runner, err := loadProjectRunner(proj, projectDirectory) + if err != nil { + return false, err + } + if runner == nil { + return false, nil + } + + runnerBinaryPath, cleanup, err := installRunnerBinary(runner) + if err != nil { + return true, err + } + defer cleanup() + + return true, executeRunnerBinary(runnerBinaryPath, projectDirectory, args) +} diff --git a/cmd/internal/run/runner_binary.go b/cmd/internal/run/runner_binary.go new file mode 100644 index 000000000..6a38f2ff3 --- /dev/null +++ b/cmd/internal/run/runner_binary.go @@ -0,0 +1,51 @@ +package run + +import ( + "fmt" + "os" + "path" + "path/filepath" + "runtime" + + "github.com/goplus/mod/modfile" +) + +func installRunnerBinary(runner *modfile.Runner) (string, func(), error) { + temporaryDirectory, err := os.MkdirTemp("", "xgo-runner-install-*") + if err != nil { + return "", nil, err + } + + cleanup := func() { + _ = os.RemoveAll(temporaryDirectory) + } + + binaryPath, err := installRunnerBinaryToDirectory(temporaryDirectory, runner.Path, runner.Version) + if err != nil { + cleanup() + return "", nil, err + } + return binaryPath, cleanup, nil +} + +func installRunnerBinaryToDirectory(targetDirectory, packagePath, version string) (string, error) { + packageReference := packageRef(packagePath, version) + output, err := runGoCommand("", []string{"GOBIN=" + targetDirectory}, "install", packageReference) + if err != nil { + return "", fmt.Errorf("install runner %s: %w\n%s", packageReference, err, output) + } + + binaryPath := filepath.Join(targetDirectory, runnerBinaryFilename(packagePath)) + if _, err := os.Stat(binaryPath); err != nil { + return "", fmt.Errorf("installed runner binary %s: %w", binaryPath, err) + } + return binaryPath, nil +} + +func runnerBinaryFilename(packagePath string) string { + filename := path.Base(packagePath) + if runtime.GOOS == "windows" { + filename += ".exe" + } + return filename +} diff --git a/cmd/internal/run/runner_command.go b/cmd/internal/run/runner_command.go new file mode 100644 index 000000000..d39e72559 --- /dev/null +++ b/cmd/internal/run/runner_command.go @@ -0,0 +1,34 @@ +package run + +import ( + "os" + "os/exec" + "strings" +) + +func executeRunnerBinary(binaryPath, projectDirectory string, args []string) error { + cmd := newCommandInDir(binaryPath, projectDirectory, append([]string{projectDirectory}, args...)...) + // Configured runners are project-controlled executables, so they intentionally + // inherit the caller environment just like other tools launched by xgo. + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + return cmd.Run() +} + +func runGoCommand(directory string, extraEnv []string, args ...string) (string, error) { + cmd := newCommandInDir("go", directory, args...) + // Runner installation/build should not be redirected by an ambient go.work file. + cmd.Env = append(os.Environ(), "GOWORK=off") + cmd.Env = append(cmd.Env, extraEnv...) + output, err := cmd.CombinedOutput() + return strings.TrimSpace(string(output)), err +} + +func newCommandInDir(command, directory string, args ...string) *exec.Cmd { + cmd := exec.Command(command, args...) + if directory != "" { + cmd.Dir = directory + } + return cmd +} diff --git a/cmd/internal/run/runner_package.go b/cmd/internal/run/runner_package.go new file mode 100644 index 000000000..1065da126 --- /dev/null +++ b/cmd/internal/run/runner_package.go @@ -0,0 +1,69 @@ +package run + +import ( + "errors" + "path/filepath" + + "github.com/goplus/mod/modcache" + "github.com/goplus/mod/modfetch" + "github.com/goplus/mod/xgomod" +) + +func downloadPackageDir(pkgPath, version string) (string, error) { + spec := packageRef(pkgPath, version) + modVer, relPath, err := modfetch.GetPkg(spec, "") + if err != nil { + return "", err + } + modDir, err := modcache.Path(modVer) + if err != nil { + return "", err + } + directory := modDir + if relPath != "" { + directory = filepath.Join(modDir, relPath) + } + return filepath.Abs(directory) +} + +func lookupPackageDir(workDir, pkgPath string) (string, error) { + mod, err := xgomod.Load(workDir) + if err = ignoreMissing(err); err != nil { + return "", err + } + if mod == nil { + return "", nil + } + + pkg, err := mod.Lookup(pkgPath) + if err = ignoreMissing(err); err != nil { + return "", err + } + if pkg == nil { + return "", nil + } + + directory, err := filepath.Abs(pkg.Dir) + if err != nil { + return "", err + } + return directory, nil +} + +func packageRef(pkgPath, version string) string { + if version == "" { + version = "latest" + } + return pkgPath + "@" + version +} + +func ignoreMissing(err error) error { + if err == nil || xgomod.IsNotFound(err) { + return nil + } + var missing *xgomod.MissingError + if errors.As(err, &missing) { + return nil + } + return err +} diff --git a/cmd/internal/run/runner_project.go b/cmd/internal/run/runner_project.go new file mode 100644 index 000000000..f68ba62dd --- /dev/null +++ b/cmd/internal/run/runner_project.go @@ -0,0 +1,151 @@ +package run + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/goplus/mod/modfile" + "github.com/goplus/xgo/x/xgoprojs" + "golang.org/x/mod/module" +) + +func resolveProjectDir(proj xgoprojs.Proj, workDir string) (string, error) { + switch v := proj.(type) { + case *xgoprojs.DirProj: + return resolvePath(workDir, v.Dir) + case *xgoprojs.FilesProj: + return resolveFilesProjectDir(workDir, v.Files) + case *xgoprojs.PkgPathProj: + return resolvePackageProjectDir(workDir, v.Path) + default: + return "", fmt.Errorf("unsupported project type %T", proj) + } +} + +func resolveFilesProjectDir(workDir string, files []string) (string, error) { + if len(files) == 0 { + return "", fmt.Errorf("no files in project") + } + return resolvePath(workDir, filepath.Dir(files[0])) +} + +func resolvePath(workDir, target string) (string, error) { + if filepath.IsAbs(target) { + return filepath.Clean(target), nil + } + if workDir == "" { + workDir = "." + } + return filepath.Abs(filepath.Join(workDir, target)) +} + +func resolvePackageProjectDir(workDir, pkgPath string) (string, error) { + pkgPath, version, _ := strings.Cut(pkgPath, "@") + if workDir == "" { + workDir = "." + } + if packageDirectory, err := lookupPackageDir(workDir, pkgPath); err != nil { + return "", err + } else if packageDirectory != "" { + return packageDirectory, nil + } + return downloadPackageDir(pkgPath, version) +} + +func loadProjectRunner(proj xgoprojs.Proj, projectDir string) (*modfile.Runner, error) { + gopModPath, data, err := readProjectGopMod(projectDir) + if err != nil { + return nil, err + } + if data == nil { + return nil, nil + } + + parsed, err := modfile.ParseLax(gopModPath, data, nil) + if err != nil { + return nil, err + } + project, err := selectTargetProject(parsed.Projects, proj, projectDir) + if err != nil { + return nil, err + } + if project == nil || project.Runner == nil { + return nil, nil + } + runner := project.Runner + if err := module.CheckImportPath(runner.Path); err != nil { + return nil, fmt.Errorf("invalid runner path %q: %w", runner.Path, err) + } + return runner, nil +} + +func selectTargetProject(projects []*modfile.Project, proj xgoprojs.Proj, projectDir string) (*modfile.Project, error) { + switch len(projects) { + case 0: + return nil, nil + case 1: + return projects[0], nil + } + + targetFilenames, err := collectTargetFilenames(proj, projectDir) + if err != nil { + return nil, err + } + + gopModPath := filepath.Join(projectDir, "gop.mod") + var matched *modfile.Project + for _, filename := range targetFilenames { + ext := modfile.ClassExt(filename) + for _, project := range projects { + if ext == project.Ext && project.IsProj(ext, filename) { + if matched != nil && matched != project { + return nil, fmt.Errorf("multiple projects in %s match run target", gopModPath) + } + matched = project + } + } + } + return matched, nil +} + +func collectTargetFilenames(proj xgoprojs.Proj, projectDir string) ([]string, error) { + switch v := proj.(type) { + case *xgoprojs.FilesProj: + filenames := make([]string, 0, len(v.Files)) + for _, file := range v.Files { + filenames = append(filenames, filepath.Base(file)) + } + return filenames, nil + case *xgoprojs.DirProj, *xgoprojs.PkgPathProj: + entries, err := os.ReadDir(projectDir) + if err != nil { + return nil, err + } + files := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + files = append(files, entry.Name()) + } + return files, nil + default: + return nil, fmt.Errorf("unsupported project type %T", proj) + } +} + +func readProjectGopMod(projectDir string) (string, []byte, error) { + gopModPath := filepath.Join(projectDir, "gop.mod") + data, err := os.ReadFile(gopModPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return gopModPath, nil, nil + } + return "", nil, err + } + return gopModPath, data, nil +} diff --git a/cmd/internal/run/runner_test.go b/cmd/internal/run/runner_test.go new file mode 100644 index 000000000..46544ddcd --- /dev/null +++ b/cmd/internal/run/runner_test.go @@ -0,0 +1,451 @@ +package run + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/goplus/mod/modfile" + "github.com/goplus/xgo/x/xgoprojs" +) + +func TestResolveProjectDir(t *testing.T) { + root := t.TempDir() + filesDir := filepath.Join(root, "files") + if err := os.MkdirAll(filesDir, 0755); err != nil { + t.Fatal(err) + } + file := filepath.Join(filesDir, "main.gop") + if err := os.WriteFile(file, []byte("package main\n"), 0644); err != nil { + t.Fatal(err) + } + + moduleRoot := filepath.Join(root, "module") + writeFile(t, filepath.Join(moduleRoot, "go.mod"), "module example.com/app\n\ngo 1.21\n") + writeFile(t, filepath.Join(moduleRoot, "pkg", "main.gop"), "package main\n") + + cases := []struct { + name string + proj xgoprojs.Proj + want string + }{ + {name: "dir", proj: &xgoprojs.DirProj{Dir: filesDir}, want: filesDir}, + {name: "files", proj: &xgoprojs.FilesProj{Files: []string{file}}, want: filesDir}, + {name: "pkg", proj: &xgoprojs.PkgPathProj{Path: "example.com/app/pkg"}, want: filepath.Join(moduleRoot, "pkg")}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := resolveProjectDir(tc.proj, moduleRoot) + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("resolveProjectDir() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestReadCommandRunnerFromGopMod(t *testing.T) { + projectDir := t.TempDir() + writeFile(t, filepath.Join(projectDir, "gop.mod"), `xgo 1.6.0 + +project main.spx Game github.com/example/app +runner example.com/runner/cmd/pcrun v1.2.3 +`) + writeFile(t, filepath.Join(projectDir, "main.spx"), "") + + runner, err := loadProjectRunner(&xgoprojs.DirProj{Dir: projectDir}, projectDir) + if err != nil { + t.Fatal(err) + } + if runner == nil { + t.Fatal("loadProjectRunner() returned nil") + } + if runner.Path != "example.com/runner/cmd/pcrun" || runner.Version != "v1.2.3" { + t.Fatalf("unexpected runner: %+v", runner) + } +} + +func TestReadCommandRunnerOnlyReadsProjectGopMod(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "gop.mod"), `xgo 1.6.0 + +project main.spx Game github.com/example/app +runner example.com/runner/cmd/pcrun +`) + projectDir := filepath.Join(root, "sub", "project") + if err := os.MkdirAll(projectDir, 0755); err != nil { + t.Fatal(err) + } + + runner, err := loadProjectRunner(&xgoprojs.DirProj{Dir: projectDir}, projectDir) + if err != nil { + t.Fatal(err) + } + if runner != nil { + t.Fatalf("loadProjectRunner() = %+v, want nil", runner) + } +} + +func TestReadCommandRunnerAbsent(t *testing.T) { + projectDir := t.TempDir() + writeFile(t, filepath.Join(projectDir, "gop.mod"), `xgo 1.6.0 + +project main.spx Game github.com/example/app +`) + writeFile(t, filepath.Join(projectDir, "main.spx"), "") + + runner, err := loadProjectRunner(&xgoprojs.DirProj{Dir: projectDir}, projectDir) + if err != nil { + t.Fatal(err) + } + if runner != nil { + t.Fatalf("loadProjectRunner() = %+v, want nil", runner) + } +} + +func TestLookupPackageDirPrefersModule(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "go.mod"), "module example.com/app\n\ngo 1.21\n") + writeFile(t, filepath.Join(root, "cmd", "runner", "main.go"), "package main\nfunc main() {}\n") + + directory, err := lookupPackageDir(root, "example.com/app/cmd/runner") + if err != nil { + t.Fatal(err) + } + if directory != filepath.Join(root, "cmd", "runner") { + t.Fatalf("lookupPackageDir() dir = %q", directory) + } +} + +func TestLookupPackageDirPropagatesLoadError(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "go.mod"), "module example.com/app\n\nrequire (\n") + + _, err := lookupPackageDir(root, "example.com/app") + if err == nil { + t.Fatal("lookupPackageDir() error = nil, want error") + } +} + +func TestLookupPackageDirPrefersReplace(t *testing.T) { + root := t.TempDir() + runnerRoot := filepath.Join(root, "runner") + appRoot := filepath.Join(root, "app") + + writeFile(t, filepath.Join(runnerRoot, "go.mod"), "module example.com/runner\n\ngo 1.21\n") + writeFile(t, filepath.Join(runnerRoot, "cmd", "pcrun", "main.go"), "package main\nfunc main() {}\n") + writeFile(t, filepath.Join(appRoot, "go.mod"), `module example.com/app + +go 1.21 + +require example.com/runner v0.0.0 + +replace example.com/runner => ../runner +`) + + directory, err := lookupPackageDir(appRoot, "example.com/runner/cmd/pcrun") + if err != nil { + t.Fatal(err) + } + if directory != filepath.Join(runnerRoot, "cmd", "pcrun") { + t.Fatalf("lookupPackageDir() dir = %q", directory) + } +} + +func TestReadCommandRunnerRejectsPathVersionSyntax(t *testing.T) { + projectDir := t.TempDir() + writeFile(t, filepath.Join(projectDir, "gop.mod"), `xgo 1.6.0 + +project main.spx Game github.com/example/app +runner example.com/runner/cmd/pcrun@latest +`) + writeFile(t, filepath.Join(projectDir, "main.spx"), "") + + _, err := loadProjectRunner(&xgoprojs.DirProj{Dir: projectDir}, projectDir) + if err == nil { + t.Fatal("loadProjectRunner() error = nil, want error") + } + if !strings.Contains(err.Error(), "invalid runner path") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestReadCommandRunnerRejectsInvalidImportPath(t *testing.T) { + projectDir := t.TempDir() + writeFile(t, filepath.Join(projectDir, "gop.mod"), `xgo 1.6.0 + +project main.spx Game github.com/example/app +runner "bad path" +`) + writeFile(t, filepath.Join(projectDir, "main.spx"), "") + + _, err := loadProjectRunner(&xgoprojs.DirProj{Dir: projectDir}, projectDir) + if err == nil { + t.Fatal("loadProjectRunner() error = nil, want error") + } + if !strings.Contains(err.Error(), "invalid runner path") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInstallRunnerUsesExplicitVersionQuery(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-based fake go test") + } + + installLog := filepath.Join(t.TempDir(), "install.log") + fakeGoDir := filepath.Join(t.TempDir(), "bin") + if err := os.MkdirAll(fakeGoDir, 0755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(fakeGoDir, "go"), `#!/bin/sh +set -eu +cmd="$1" +shift +case "$cmd" in +install) + printf '%s\n' "$1" >>"$FAKE_GO_INSTALL_LOG" + cat >"$GOBIN/pcrun" <<'EOF' +#!/bin/sh +printf 'remote' > "$1" +EOF + chmod +x "$GOBIN/pcrun" + exit 0 + ;; +esac +echo "unexpected go command: $cmd $*" >&2 +exit 1 +`) + if err := os.Chmod(filepath.Join(fakeGoDir, "go"), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("FAKE_GO_INSTALL_LOG", installLog) + t.Setenv("PATH", fakeGoDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + binaryPath, cleanup, err := installRunnerBinary(&modfile.Runner{Path: "example.com/runner/cmd/pcrun", Version: "v1.2.3"}) + if err != nil { + t.Fatal(err) + } + defer cleanup() + + data, err := os.ReadFile(installLog) + if err != nil { + t.Fatal(err) + } + lines := strings.Fields(string(data)) + if len(lines) != 1 || lines[0] != "example.com/runner/cmd/pcrun@v1.2.3" { + t.Fatalf("unexpected install log: %q", data) + } + + outputFile := filepath.Join(t.TempDir(), "runner.out") + cmd := exec.Command(binaryPath, outputFile) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("run temp runner: %v\n%s", err, out) + } + got, err := os.ReadFile(outputFile) + if err != nil { + t.Fatal(err) + } + if string(got) != "remote" { + t.Fatalf("temp runner output = %q, want remote", got) + } +} + +func TestInstallRunnerUsesLatestQuery(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-based fake go test") + } + + installLog := filepath.Join(t.TempDir(), "install.log") + fakeGoDir := filepath.Join(t.TempDir(), "bin") + if err := os.MkdirAll(fakeGoDir, 0755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(fakeGoDir, "go"), `#!/bin/sh +set -eu +cmd="$1" +shift +case "$cmd" in +install) + printf '%s\n' "$1" >>"$FAKE_GO_INSTALL_LOG" + cat >"$GOBIN/pcrun" <<'EOF' +#!/bin/sh +printf 'remote-latest' > "$1" +EOF + chmod +x "$GOBIN/pcrun" + exit 0 + ;; +esac +echo "unexpected go command: $cmd $*" >&2 +exit 1 +`) + if err := os.Chmod(filepath.Join(fakeGoDir, "go"), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("FAKE_GO_INSTALL_LOG", installLog) + t.Setenv("PATH", fakeGoDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + binaryPath, cleanup, err := installRunnerBinary(&modfile.Runner{Path: "example.com/runner/cmd/pcrun", Version: "latest"}) + if err != nil { + t.Fatal(err) + } + defer cleanup() + + data, err := os.ReadFile(installLog) + if err != nil { + t.Fatal(err) + } + lines := strings.Fields(string(data)) + if len(lines) != 1 || lines[0] != "example.com/runner/cmd/pcrun@latest" { + t.Fatalf("unexpected install log: %q", data) + } + + outputFile := filepath.Join(t.TempDir(), "runner.out") + cmd := exec.Command(binaryPath, outputFile) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("run temp runner: %v\n%s", err, out) + } + got, err := os.ReadFile(outputFile) + if err != nil { + t.Fatal(err) + } + if string(got) != "remote-latest" { + t.Fatalf("temp runner output = %q, want remote-latest", got) + } +} + +func TestRunWithConfiguredRunner(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-based fake go test") + } + + projectDir := t.TempDir() + outputFile := filepath.Join(t.TempDir(), "runner.out") + fakeGoDir := filepath.Join(t.TempDir(), "bin") + if err := os.MkdirAll(fakeGoDir, 0755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(fakeGoDir, "go"), `#!/bin/sh +set -eu +cmd="$1" +shift +case "$cmd" in +install) + cat >"$GOBIN/pcrun" <<'EOF' +#!/bin/sh +set -eu +{ + printf '%s\n' "$1" + shift + printf '%s' "$*" +} >"$TEST_RUNNER_OUTPUT" +EOF + chmod +x "$GOBIN/pcrun" + exit 0 + ;; +esac +echo "unexpected go command: $cmd $*" >&2 +exit 1 +`) + if err := os.Chmod(filepath.Join(fakeGoDir, "go"), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", fakeGoDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("TEST_RUNNER_OUTPUT", outputFile) + writeFile(t, filepath.Join(projectDir, "gop.mod"), `xgo 1.6.0 + +project main.spx Game github.com/example/app +runner example.com/runner/cmd/pcrun +`) + writeFile(t, filepath.Join(projectDir, "main.spx"), "") + + handled, err := runWithConfiguredRunner(&xgoprojs.DirProj{Dir: projectDir}, []string{"alpha", "beta"}, ".") + if err != nil { + t.Fatal(err) + } + if !handled { + t.Fatal("runWithConfiguredRunner() = false, want true") + } + + data, err := os.ReadFile(outputFile) + if err != nil { + t.Fatal(err) + } + got := string(data) + want := projectDir + "\nalpha beta" + if got != want { + t.Fatalf("runner output = %q, want %q", got, want) + } +} + +func TestReadCommandRunnerSelectsMatchingProjectForFiles(t *testing.T) { + projectDir := t.TempDir() + alphaFile := filepath.Join(projectDir, "main.alpha") + betaFile := filepath.Join(projectDir, "main.beta") + writeFile(t, filepath.Join(projectDir, "gop.mod"), `xgo 1.6.0 + +project main.alpha App github.com/example/alpha +runner example.com/runner/cmd/alpha + +project main.beta App github.com/example/beta +runner example.com/runner/cmd/beta +`) + writeFile(t, alphaFile, "") + writeFile(t, betaFile, "") + + runner, err := loadProjectRunner(&xgoprojs.FilesProj{Files: []string{betaFile}}, projectDir) + if err != nil { + t.Fatal(err) + } + if runner == nil || runner.Path != "example.com/runner/cmd/beta" { + t.Fatalf("loadProjectRunner() = %+v, want beta runner", runner) + } +} + +func TestReadCommandRunnerRejectsAmbiguousDirectoryProject(t *testing.T) { + projectDir := t.TempDir() + writeFile(t, filepath.Join(projectDir, "gop.mod"), `xgo 1.6.0 + +project main.alpha App github.com/example/alpha +runner example.com/runner/cmd/alpha + +project main.beta App github.com/example/beta +runner example.com/runner/cmd/beta +`) + writeFile(t, filepath.Join(projectDir, "main.alpha"), "") + writeFile(t, filepath.Join(projectDir, "main.beta"), "") + + _, err := loadProjectRunner(&xgoprojs.DirProj{Dir: projectDir}, projectDir) + if err == nil { + t.Fatal("loadProjectRunner() error = nil, want error") + } + if !strings.Contains(err.Error(), "multiple projects") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunnerBinaryFilename(t *testing.T) { + name := runnerBinaryFilename("example.com/runner/cmd/pcrun") + if runtime.GOOS == "windows" && name != "pcrun.exe" { + t.Fatalf("windows runner binary = %q, want pcrun.exe", name) + } + if runtime.GOOS != "windows" && name != "pcrun" { + t.Fatalf("non-windows runner binary = %q, want pcrun", name) + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +}