diff --git a/xtool/llar/llar.go b/xtool/llar/llar.go new file mode 100644 index 0000000000..b49605ef75 --- /dev/null +++ b/xtool/llar/llar.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package llar provides a small Go wrapper around the llar command. +package llar + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os/exec" + "sort" + "strings" +) + +// Cmd represents an llar command. +type Cmd struct { + bin string + + Stdout io.Writer + Stderr io.Writer +} + +// New creates an llar command wrapper. An empty bin uses llar from PATH. +func New(bin string) *Cmd { + if bin == "" { + bin = "llar" + } + return &Cmd{bin: bin} +} + +// Module identifies an llar module to install. +type Module struct { + Path string + Version string +} + +// Config selects the build matrix for an installation. +type Config struct { + To string + OS string + Arch string + Libc string + Options map[string][]string +} + +// Dependency is a module returned in an llar install result. +type Dependency struct { + Path string `json:"path"` + Version string `json:"version"` + Dir string `json:"dir"` +} + +// Result is the JSON result returned by llar install. +type Result struct { + Path string `json:"path"` + Version string `json:"version"` + Dir string `json:"dir"` + Deps []Dependency `json:"deps,omitempty"` + BuildFlags string `json:"metadata"` +} + +// Install runs llar install for mod and decodes the command's JSON result. +func (p *Cmd) Install(mod Module, config Config) (Result, error) { + args := []string{"install", "--json"} + if config.To != "" { + args = append(args, "--output", config.To) + } + if p.Stderr != nil { + args = append(args, "--verbose") + } + if config.OS != "" { + args = append(args, "--os", config.OS) + } + if config.Arch != "" { + args = append(args, "--arch", config.Arch) + } + if config.Libc != "" { + args = append(args, "--libc", config.Libc) + } + + keys := make([]string, 0, len(config.Options)) + for key := range config.Options { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + for _, value := range config.Options[key] { + args = append(args, "--option", key+"="+value) + } + } + target := mod.Path + if mod.Version != "" { + target += "@" + mod.Version + } + args = append(args, target) + + var stdout, stderr bytes.Buffer + cmd := exec.Command(p.bin, args...) + cmd.Stdout = io.Writer(&stdout) + if p.Stdout != nil { + cmd.Stdout = io.MultiWriter(&stdout, p.Stdout) + } + cmd.Stderr = io.Writer(&stderr) + if p.Stderr != nil { + cmd.Stderr = io.MultiWriter(&stderr, p.Stderr) + } + if err := cmd.Run(); err != nil { + if message := strings.TrimSpace(stderr.String()); message != "" { + return Result{}, fmt.Errorf("llar install: %w: %s", err, message) + } + return Result{}, fmt.Errorf("llar install: %w", err) + } + + var result Result + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + return Result{}, fmt.Errorf("llar install: decode result: %w", err) + } + return result, nil +} diff --git a/xtool/llar/llar_test.go b/xtool/llar/llar_test.go new file mode 100644 index 0000000000..54251974c8 --- /dev/null +++ b/xtool/llar/llar_test.go @@ -0,0 +1,150 @@ +package llar + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFakeLLAR(t *testing.T, output string) (bin, argsFile string) { + t.Helper() + dir := t.TempDir() + bin = filepath.Join(dir, "llar") + argsFile = filepath.Join(dir, "args") + script := "#!/bin/sh\n" + + "printf '%s\\n' \"$@\" > \"$LLAR_TEST_ARGS\"\n" + + "printf '%s\\n' 'progress' >&2\n" + + "printf '%s\\n' '" + output + "'\n" + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("LLAR_TEST_ARGS", argsFile) + return bin, argsFile +} + +func readArgs(t *testing.T, path string) []string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") +} + +func TestInstall(t *testing.T) { + bin, argsFile := writeFakeLLAR(t, `{"path":"owner/root","version":"v1.2.3","dir":"/tmp/root","deps":[{"path":"owner/dep","version":"v1.0.0","dir":"/tmp/dep"}],"metadata":"-L/tmp/root/lib -lroot"}`) + + var rawStdout, rawStderr bytes.Buffer + cmd := New(bin) + cmd.Stdout = &rawStdout + cmd.Stderr = &rawStderr + result, err := cmd.Install(Module{Path: "owner/root", Version: "v1.2.3"}, Config{ + To: "/tmp/root", + OS: "linux", + Arch: "amd64", + Libc: "glibc", + Options: map[string][]string{ + "zlib": {"system", "bundled"}, + "debug": {"true"}, + }, + }) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + if result.Path != "owner/root" || result.Version != "v1.2.3" || result.Dir != "/tmp/root" { + t.Fatalf("result = %+v", result) + } + if len(result.Deps) != 1 || result.Deps[0].Path != "owner/dep" { + t.Fatalf("deps = %+v", result.Deps) + } + if result.BuildFlags != "-L/tmp/root/lib -lroot" { + t.Fatalf("build flags = %q", result.BuildFlags) + } + if !strings.Contains(rawStdout.String(), `"path":"owner/root"`) { + t.Fatalf("stdout = %q, want JSON result", rawStdout.String()) + } + if rawStderr.String() != "progress\n" { + t.Fatalf("stderr = %q, want progress output", rawStderr.String()) + } + + wantArgs := []string{ + "install", "--json", "--output", "/tmp/root", + "--verbose", + "--os", "linux", "--arch", "amd64", "--libc", "glibc", + "--option", "debug=true", + "--option", "zlib=system", + "--option", "zlib=bundled", + "owner/root@v1.2.3", + } + gotArgs := readArgs(t, argsFile) + if len(gotArgs) != len(wantArgs) { + t.Fatalf("args = %q, want %q", gotArgs, wantArgs) + } + for i := range wantArgs { + if gotArgs[i] != wantArgs[i] { + t.Fatalf("args[%d] = %q, want %q", i, gotArgs[i], wantArgs[i]) + } + } +} + +func TestInstallWithoutOutput(t *testing.T) { + bin, argsFile := writeFakeLLAR(t, `{"path":"owner/root","version":"v1.0.0","metadata":"-lroot"}`) + if _, err := New(bin).Install(Module{Path: "owner/root", Version: "v1.0.0"}, Config{}); err != nil { + t.Fatalf("Install() error = %v", err) + } + + want := []string{"install", "--json", "owner/root@v1.0.0"} + got := readArgs(t, argsFile) + if len(got) != len(want) { + t.Fatalf("args = %q, want %q", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("args[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestInstallReturnsCommandError(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "llar") + script := "#!/bin/sh\nprintf '%s\\n' 'install failed' >&2\nexit 7\n" + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + _, err := New(bin).Install(Module{Path: "owner/root"}, Config{To: t.TempDir()}) + if err == nil || !strings.Contains(err.Error(), "install failed") || !strings.Contains(err.Error(), "exit status 7") { + t.Fatalf("error = %v", err) + } +} + +func TestInstallReturnsJSONError(t *testing.T) { + bin, _ := writeFakeLLAR(t, "not-json") + _, err := New(bin).Install(Module{Path: "owner/root"}, Config{}) + if err == nil || !strings.Contains(err.Error(), "decode result") { + t.Fatalf("error = %v", err) + } +} + +func TestResultJSONRoundTrip(t *testing.T) { + want := Result{ + Path: "owner/root", Version: "v1.0.0", Dir: "/tmp/root", + Deps: []Dependency{{Path: "owner/dep", Version: "v1.0.0", Dir: "/tmp/dep"}}, + BuildFlags: "-lroot", + } + data, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + var got Result + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.Path != want.Path || got.Version != want.Version || got.Dir != want.Dir || got.BuildFlags != want.BuildFlags || len(got.Deps) != 1 { + t.Fatalf("round trip = %+v, want %+v", got, want) + } +}