feat(build): add rust project builder
This commit is contained in:
parent
91037a9443
commit
b565752f9f
8 changed files with 359 additions and 3 deletions
|
|
@ -392,6 +392,8 @@ func getBuilder(projectType build.ProjectType) (build.Builder, error) {
|
|||
return builders.NewNodeBuilder(), nil
|
||||
case build.ProjectTypePHP:
|
||||
return builders.NewPHPBuilder(), nil
|
||||
case build.ProjectTypeRust:
|
||||
return builders.NewRustBuilder(), nil
|
||||
case build.ProjectTypeDocs:
|
||||
return builders.NewDocsBuilder(), nil
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ type Artifact struct {
|
|||
| **WailsBuilder** | `wails.json` | Checks `go.mod` for Wails v3 vs v2. V3 delegates to TaskfileBuilder; V2 runs `wails build -platform` then copies from `build/bin/` to `dist/`. |
|
||||
| **NodeBuilder** | `package.json` | Detects the active package manager from lockfiles, runs the build script once per target, and collects artifacts from `dist/{os}_{arch}/`. |
|
||||
| **PHPBuilder** | `composer.json` | Runs `composer install`, then `composer run-script build` when present. Falls back to a deterministic zip bundle in `dist/{os}_{arch}/`. |
|
||||
| **RustBuilder** | `Cargo.toml` | Runs `cargo build --release --target` per platform and collects executables from `target/{triple}/release/`. |
|
||||
| **DocsBuilder** | `mkdocs.yml` | Runs `mkdocs build --clean --site-dir` and packages the generated `site/` tree into a zip bundle per target. |
|
||||
| **DockerBuilder** | `Dockerfile` | Validates `docker` and `buildx`, builds multi-platform images with `docker buildx build --platform`. Supports `--push` or local load/OCI tarball. |
|
||||
| **LinuxKitBuilder** | `linuxkit.yml` or `.core/linuxkit/*.yml` | Validates `linuxkit` CLI, runs `linuxkit build --format --name --dir --arch`. Outputs qcow2, iso, raw, vmdk, vhd, or cloud images. Linux-only targets. |
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ description: Build system, release pipeline, and SDK generation for the Core eco
|
|||
|
||||
`forge.lthn.ai/core/go-build` is the build, release, and SDK generation toolkit for Core projects. It provides:
|
||||
|
||||
- **Auto-detecting builders** for Go, Wails, Node, PHP, Docs, Docker, LinuxKit, C++, and Taskfile projects
|
||||
- **Auto-detecting builders** for Go, Wails, Node, PHP, Rust, Docs, Docker, LinuxKit, C++, and Taskfile projects
|
||||
- **Cross-compilation** with per-target archiving (tar.gz, tar.xz, zip) and SHA-256 checksums
|
||||
- **Code signing** -- macOS codesign with notarisation, GPG detached signatures, Windows signtool
|
||||
- **Release automation** -- semantic versioning from git tags, conventional-commit changelogs, multi-target publishing
|
||||
|
|
@ -43,6 +43,7 @@ The builder is chosen by marker-file priority:
|
|||
| `go.mod` | Go |
|
||||
| `package.json` | Node |
|
||||
| `composer.json` | PHP |
|
||||
| `Cargo.toml` | Rust |
|
||||
| `mkdocs.yml` | Docs |
|
||||
| `CMakeLists.txt` | C++ |
|
||||
| `Dockerfile` | Docker |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Package build provides project type detection and cross-compilation for the Core build system.
|
||||
// It supports Go, Wails, Node.js, and PHP projects with automatic detection based on
|
||||
// marker files (go.mod, wails.json, package.json, composer.json).
|
||||
// It supports Go, Wails, Node.js, PHP, and Rust projects with automatic detection based on
|
||||
// marker files (go.mod, wails.json, package.json, composer.json, Cargo.toml).
|
||||
package build
|
||||
|
||||
import (
|
||||
|
|
|
|||
195
pkg/build/builders/rust.go
Normal file
195
pkg/build/builders/rust.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
// Package builders provides build implementations for different project types.
|
||||
package builders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"dappco.re/go/core/build/internal/ax"
|
||||
"dappco.re/go/core/build/pkg/build"
|
||||
"dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// RustBuilder implements the Builder interface for Rust projects.
|
||||
//
|
||||
// b := builders.NewRustBuilder()
|
||||
type RustBuilder struct{}
|
||||
|
||||
// NewRustBuilder creates a new RustBuilder instance.
|
||||
//
|
||||
// b := builders.NewRustBuilder()
|
||||
func NewRustBuilder() *RustBuilder {
|
||||
return &RustBuilder{}
|
||||
}
|
||||
|
||||
// Name returns the builder's identifier.
|
||||
//
|
||||
// name := b.Name() // → "rust"
|
||||
func (b *RustBuilder) Name() string {
|
||||
return "rust"
|
||||
}
|
||||
|
||||
// Detect checks if this builder can handle the project in the given directory.
|
||||
//
|
||||
// ok, err := b.Detect(io.Local, ".")
|
||||
func (b *RustBuilder) Detect(fs io.Medium, dir string) (bool, error) {
|
||||
return build.IsRustProject(fs, dir), nil
|
||||
}
|
||||
|
||||
// Build compiles the Rust project for the specified targets.
|
||||
//
|
||||
// artifacts, err := b.Build(ctx, cfg, []build.Target{{OS: "linux", Arch: "amd64"}})
|
||||
func (b *RustBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) {
|
||||
if cfg == nil {
|
||||
return nil, coreerr.E("RustBuilder.Build", "config is nil", nil)
|
||||
}
|
||||
|
||||
cargoCommand, err := b.resolveCargoCli()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
targets = []build.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}
|
||||
}
|
||||
|
||||
outputDir := cfg.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = ax.Join(cfg.ProjectDir, "dist")
|
||||
}
|
||||
if err := cfg.FS.EnsureDir(outputDir); err != nil {
|
||||
return nil, coreerr.E("RustBuilder.Build", "failed to create output directory", err)
|
||||
}
|
||||
|
||||
var artifacts []build.Artifact
|
||||
for _, target := range targets {
|
||||
targetTriple, err := rustTargetTriple(target)
|
||||
if err != nil {
|
||||
return artifacts, err
|
||||
}
|
||||
|
||||
platformDir := ax.Join(outputDir, core.Sprintf("%s_%s", target.OS, target.Arch))
|
||||
if err := cfg.FS.EnsureDir(platformDir); err != nil {
|
||||
return artifacts, coreerr.E("RustBuilder.Build", "failed to create platform directory", err)
|
||||
}
|
||||
|
||||
env := append([]string{}, cfg.Env...)
|
||||
env = append(env,
|
||||
core.Sprintf("CARGO_TARGET_DIR=%s", platformDir),
|
||||
core.Sprintf("TARGET_OS=%s", target.OS),
|
||||
core.Sprintf("TARGET_ARCH=%s", target.Arch),
|
||||
)
|
||||
if cfg.Name != "" {
|
||||
env = append(env, core.Sprintf("NAME=%s", cfg.Name))
|
||||
}
|
||||
if cfg.Version != "" {
|
||||
env = append(env, core.Sprintf("VERSION=%s", cfg.Version))
|
||||
}
|
||||
|
||||
args := []string{"build", "--release", "--target", targetTriple}
|
||||
output, err := ax.CombinedOutput(ctx, cfg.ProjectDir, env, cargoCommand, args...)
|
||||
if err != nil {
|
||||
return artifacts, coreerr.E("RustBuilder.Build", "cargo build failed: "+output, err)
|
||||
}
|
||||
|
||||
found := b.findArtifactsForTarget(cfg.FS, platformDir, targetTriple, target)
|
||||
if len(found) == 0 {
|
||||
return artifacts, coreerr.E("RustBuilder.Build", "no build artifacts found for "+target.String(), nil)
|
||||
}
|
||||
|
||||
artifacts = append(artifacts, found...)
|
||||
}
|
||||
|
||||
return artifacts, nil
|
||||
}
|
||||
|
||||
// resolveCargoCli returns the executable path for cargo.
|
||||
//
|
||||
// command, err := b.resolveCargoCli()
|
||||
func (b *RustBuilder) resolveCargoCli(paths ...string) (string, error) {
|
||||
if len(paths) == 0 {
|
||||
paths = []string{
|
||||
"/usr/local/bin/cargo",
|
||||
"/opt/homebrew/bin/cargo",
|
||||
}
|
||||
}
|
||||
|
||||
command, err := ax.ResolveCommand("cargo", paths...)
|
||||
if err != nil {
|
||||
return "", coreerr.E("RustBuilder.resolveCargoCli", "cargo CLI not found. Install Rust from https://www.rust-lang.org/tools/install", err)
|
||||
}
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
// findArtifactsForTarget looks for compiled binaries in the cargo target directory.
|
||||
func (b *RustBuilder) findArtifactsForTarget(fs io.Medium, targetDir, targetTriple string, target build.Target) []build.Artifact {
|
||||
releaseDir := ax.Join(targetDir, targetTriple, "release")
|
||||
if !fs.IsDir(releaseDir) {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries, err := fs.List(releaseDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var artifacts []build.Artifact
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
if core.HasPrefix(name, ".") ||
|
||||
core.HasSuffix(name, ".d") ||
|
||||
core.HasSuffix(name, ".rlib") ||
|
||||
core.HasSuffix(name, ".rmeta") ||
|
||||
core.HasSuffix(name, ".a") ||
|
||||
core.HasSuffix(name, ".lib") ||
|
||||
core.HasSuffix(name, ".pdb") {
|
||||
continue
|
||||
}
|
||||
|
||||
fullPath := ax.Join(releaseDir, name)
|
||||
if target.OS != "windows" {
|
||||
info, statErr := fs.Stat(fullPath)
|
||||
if statErr != nil || info.Mode()&0o111 == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
artifacts = append(artifacts, build.Artifact{
|
||||
Path: fullPath,
|
||||
OS: target.OS,
|
||||
Arch: target.Arch,
|
||||
})
|
||||
}
|
||||
|
||||
return artifacts
|
||||
}
|
||||
|
||||
// rustTargetTriple maps a build target to a Rust target triple.
|
||||
func rustTargetTriple(target build.Target) (string, error) {
|
||||
switch target.OS + "/" + target.Arch {
|
||||
case "linux/amd64":
|
||||
return "x86_64-unknown-linux-gnu", nil
|
||||
case "linux/arm64":
|
||||
return "aarch64-unknown-linux-gnu", nil
|
||||
case "darwin/amd64":
|
||||
return "x86_64-apple-darwin", nil
|
||||
case "darwin/arm64":
|
||||
return "aarch64-apple-darwin", nil
|
||||
case "windows/amd64":
|
||||
return "x86_64-pc-windows-msvc", nil
|
||||
case "windows/arm64":
|
||||
return "aarch64-pc-windows-msvc", nil
|
||||
default:
|
||||
return "", coreerr.E("RustBuilder.rustTargetTriple", "unsupported Rust target: "+target.String(), nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure RustBuilder implements the Builder interface.
|
||||
var _ build.Builder = (*RustBuilder)(nil)
|
||||
148
pkg/build/builders/rust_test.go
Normal file
148
pkg/build/builders/rust_test.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package builders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"dappco.re/go/core/build/internal/ax"
|
||||
"dappco.re/go/core/build/pkg/build"
|
||||
"dappco.re/go/core/io"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupFakeRustToolchain(t *testing.T, binDir string) {
|
||||
t.Helper()
|
||||
|
||||
script := `#!/bin/sh
|
||||
set -eu
|
||||
|
||||
log_file="${RUST_BUILD_LOG_FILE:-}"
|
||||
if [ -n "$log_file" ]; then
|
||||
printf '%s\n' "$(basename "$0")" >> "$log_file"
|
||||
printf '%s\n' "$@" >> "$log_file"
|
||||
printf '%s\n' "CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-}" >> "$log_file"
|
||||
printf '%s\n' "TARGET_OS=${TARGET_OS:-}" >> "$log_file"
|
||||
printf '%s\n' "TARGET_ARCH=${TARGET_ARCH:-}" >> "$log_file"
|
||||
fi
|
||||
|
||||
target_triple=""
|
||||
prev=""
|
||||
for arg in "$@"; do
|
||||
if [ "$prev" = "--target" ]; then
|
||||
target_triple="$arg"
|
||||
prev=""
|
||||
continue
|
||||
fi
|
||||
if [ "$arg" = "--target" ]; then
|
||||
prev="--target"
|
||||
fi
|
||||
done
|
||||
|
||||
target_dir="${CARGO_TARGET_DIR:-target}"
|
||||
release_dir="$target_dir/$target_triple/release"
|
||||
mkdir -p "$release_dir"
|
||||
|
||||
name="${NAME:-rustapp}"
|
||||
artifact="$release_dir/$name"
|
||||
case "$target_triple" in
|
||||
*-windows-*)
|
||||
artifact="$artifact.exe"
|
||||
;;
|
||||
esac
|
||||
|
||||
printf 'fake rust artifact\n' > "$artifact"
|
||||
chmod +x "$artifact" 2>/dev/null || true
|
||||
`
|
||||
|
||||
require.NoError(t, ax.WriteFile(ax.Join(binDir, "cargo"), []byte(script), 0o755))
|
||||
}
|
||||
|
||||
func setupRustTestProject(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, ax.WriteFile(ax.Join(dir, "Cargo.toml"), []byte("[package]\nname = \"testapp\"\nversion = \"0.1.0\""), 0o644))
|
||||
require.NoError(t, ax.MkdirAll(ax.Join(dir, "src"), 0o755))
|
||||
require.NoError(t, ax.WriteFile(ax.Join(dir, "src", "main.rs"), []byte("fn main() {}"), 0o644))
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestRust_RustBuilderName_Good(t *testing.T) {
|
||||
builder := NewRustBuilder()
|
||||
assert.Equal(t, "rust", builder.Name())
|
||||
}
|
||||
|
||||
func TestRust_RustBuilderDetect_Good(t *testing.T) {
|
||||
fs := io.Local
|
||||
|
||||
t.Run("detects Cargo.toml projects", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, ax.WriteFile(ax.Join(dir, "Cargo.toml"), []byte("{}"), 0o644))
|
||||
|
||||
builder := NewRustBuilder()
|
||||
detected, err := builder.Detect(fs, dir)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, detected)
|
||||
})
|
||||
|
||||
t.Run("returns false for empty directory", func(t *testing.T) {
|
||||
builder := NewRustBuilder()
|
||||
detected, err := builder.Detect(fs, t.TempDir())
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, detected)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRust_RustBuilderBuild_Good(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
binDir := t.TempDir()
|
||||
setupFakeRustToolchain(t, binDir)
|
||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
projectDir := setupRustTestProject(t)
|
||||
outputDir := t.TempDir()
|
||||
logDir := t.TempDir()
|
||||
logPath := ax.Join(logDir, "rust.log")
|
||||
t.Setenv("RUST_BUILD_LOG_FILE", logPath)
|
||||
|
||||
builder := NewRustBuilder()
|
||||
cfg := &build.Config{
|
||||
FS: io.Local,
|
||||
ProjectDir: projectDir,
|
||||
OutputDir: outputDir,
|
||||
Name: "testapp",
|
||||
Version: "v1.2.3",
|
||||
}
|
||||
|
||||
targets := []build.Target{{OS: "linux", Arch: "amd64"}}
|
||||
|
||||
artifacts, err := builder.Build(context.Background(), cfg, targets)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, artifacts, 1)
|
||||
assert.FileExists(t, artifacts[0].Path)
|
||||
assert.Equal(t, "linux", artifacts[0].OS)
|
||||
assert.Equal(t, "amd64", artifacts[0].Arch)
|
||||
|
||||
content, err := ax.ReadFile(logPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
|
||||
require.GreaterOrEqual(t, len(lines), 5)
|
||||
assert.Equal(t, "cargo", lines[0])
|
||||
assert.Equal(t, "build", lines[1])
|
||||
assert.Equal(t, "--release", lines[2])
|
||||
assert.Equal(t, "--target", lines[3])
|
||||
assert.Equal(t, "x86_64-unknown-linux-gnu", lines[4])
|
||||
assert.Contains(t, lines, "CARGO_TARGET_DIR="+ax.Join(outputDir, "linux_amd64"))
|
||||
}
|
||||
|
||||
func TestRust_RustBuilderInterface_Good(t *testing.T) {
|
||||
var _ build.Builder = (*RustBuilder)(nil)
|
||||
var _ build.Builder = NewRustBuilder()
|
||||
}
|
||||
|
|
@ -331,6 +331,8 @@ func getBuilder(projectType build.ProjectType) (build.Builder, error) {
|
|||
return builders.NewNodeBuilder(), nil
|
||||
case build.ProjectTypePHP:
|
||||
return builders.NewPHPBuilder(), nil
|
||||
case build.ProjectTypeRust:
|
||||
return builders.NewRustBuilder(), nil
|
||||
case build.ProjectTypeDocs:
|
||||
return builders.NewDocsBuilder(), nil
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -185,6 +185,13 @@ func TestRelease_GetBuilder_Good(t *testing.T) {
|
|||
assert.NotNil(t, builder)
|
||||
assert.Equal(t, "php", builder.Name())
|
||||
})
|
||||
|
||||
t.Run("returns Rust builder for rust project type", func(t *testing.T) {
|
||||
builder, err := getBuilder(build.ProjectTypeRust)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, builder)
|
||||
assert.Equal(t, "rust", builder.Name())
|
||||
})
|
||||
}
|
||||
|
||||
func TestRelease_GetBuilder_Bad(t *testing.T) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue