From b565752f9fa20b6a286da387b3910d43916cd52f Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 11:14:23 +0000 Subject: [PATCH] feat(build): add rust project builder --- cmd/build/cmd_project.go | 2 + docs/architecture.md | 1 + docs/index.md | 3 +- pkg/build/build.go | 4 +- pkg/build/builders/rust.go | 195 ++++++++++++++++++++++++++++++++ pkg/build/builders/rust_test.go | 148 ++++++++++++++++++++++++ pkg/release/release.go | 2 + pkg/release/release_test.go | 7 ++ 8 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 pkg/build/builders/rust.go create mode 100644 pkg/build/builders/rust_test.go diff --git a/cmd/build/cmd_project.go b/cmd/build/cmd_project.go index b64ccad..f21faf9 100644 --- a/cmd/build/cmd_project.go +++ b/cmd/build/cmd_project.go @@ -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: diff --git a/docs/architecture.md b/docs/architecture.md index 185bc78..ee53304 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. | diff --git a/docs/index.md b/docs/index.md index de92cc7..402df27 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 | diff --git a/pkg/build/build.go b/pkg/build/build.go index 05d1b84..bd89abb 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -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 ( diff --git a/pkg/build/builders/rust.go b/pkg/build/builders/rust.go new file mode 100644 index 0000000..9eff393 --- /dev/null +++ b/pkg/build/builders/rust.go @@ -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) diff --git a/pkg/build/builders/rust_test.go b/pkg/build/builders/rust_test.go new file mode 100644 index 0000000..65d5c6a --- /dev/null +++ b/pkg/build/builders/rust_test.go @@ -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() +} diff --git a/pkg/release/release.go b/pkg/release/release.go index 0166e1a..b926827 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -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: diff --git a/pkg/release/release_test.go b/pkg/release/release_test.go index 60fa013..28c04f3 100644 --- a/pkg/release/release_test.go +++ b/pkg/release/release_test.go @@ -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) {