Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions xtool/llar/llar.go
Original file line number Diff line number Diff line change
@@ -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
}
150 changes: 150 additions & 0 deletions xtool/llar/llar_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading