Compare commits

..

4 Commits

Author SHA1 Message Date
Catty Steve 681b7d1333 style: format all code to comply with cargo fmt check
CI / ${{ matrix.crate }} (workshop-engine) (push) Failing after 36m14s
CI / ${{ matrix.crate }} (workshop-baker) (push) Successful in 1h35m51s
2026-04-28 19:17:56 +08:00
Catty Steve f07588a716 test: add Go UPM system tests for bare prebake 2026-04-28 19:12:53 +08:00
Catty Steve b0530fb858 feat(upm): add Python and Go package manager support 2026-04-28 16:56:53 +08:00
Catty Steve fd18b547e5 feat(upm): implement Rust user package manager
Adds the `user.rust` UPM to workshop-engine for managing Rust
toolchains,
components, cross-compilation targets, and cargo operations:

- `rustup default <toolchain>` / `rustup component add` / `rustup target
  add`
- `cargo fetch` for dependency pre-fetch with optional `--locked`
- `cargo install` for global crate tools with registry support

Includes 275-line test suite (`tests/upm/test_rust.py`) covering the
full
lifecycle with optional network tests.
2026-04-28 12:20:05 +08:00
26 changed files with 1718 additions and 255 deletions
+2
View File
@@ -18,3 +18,5 @@ tests/**/__pycache__/
tests/.pytest_cache/
tests/llm/.cache/
*.pyc
.staging
.pytest_cache
+171 -55
View File
@@ -4,97 +4,213 @@ set -euo pipefail
# HoneyBiscuitWorkshop System Test Runner
#
# Usage:
# ./runner.sh # Arch baker tests (hbw-system-test:latest)
# ./runner.sh --image hbw-test-pm:test-apt # Debian engine PM tests
# ./runner.sh -k test_install # Arch baker, filtered
MODE="auto"
TEST_IMAGE=""
TEST_DIR="tests/baker"
PYTEST_ARGS=""
while [[ $# -gt 0 ]]; do
case "$1" in
--image) TEST_IMAGE="$2"; TEST_DIR="tests/engine"; shift 2 ;;
manual) MODE="manual"; shift ;;
auto) MODE="auto"; shift ;;
--) shift; PYTEST_ARGS="$@"; break ;;
*) PYTEST_ARGS="$PYTEST_ARGS $1"; shift ;;
esac
done
# Default image: Arch-based baker test image
: ${TEST_IMAGE:="hbw-system-test:latest"}
# ./runner.sh # baker tests (default)
# ./runner.sh -c upm:rust # Rust UPM tests
# ./runner.sh -c pm:pacman # pacman PM engine tests
# ./runner.sh -c pm:apt -k test_mirror # apt mirror test only
# ./runner.sh -c upm:rust --network # include network tests
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONTAINER_NAME="hbw-system-test-container"
STAGING_DIR="/tmp/hbw-system-test"
STAGING_DIR="$SCRIPT_DIR/.staging"
# ── defaults ────────────────────────────────────────────────────────
MODE="auto"
CATEGORY="baker" # baker | upm:<name> | pm:<name>
TEST_DIR=""
TEST_IMAGE=""
PYTEST_ARGS=()
NETWORK=0
BAKER_LOG=1
# ── parse args ──────────────────────────────────────────────────────
show_help() {
cat << 'HELP'
Usage: ./runner.sh [OPTIONS]
Categories (-c):
baker Baker system tests [default]
upm:rust Rust UPM tests
pm:pacman Pacman PM engine tests
pm:apt APT PM engine tests
pm:dnf DNF PM engine tests
pm:apk APK PM engine tests
Options:
-c, --category CAT Test category (see above)
--network Allow network-dependent tests (toolchain/crate downloads)
-k FILTER pytest -k expression
--timeout SECONDS Per-test timeout (default 120)
--log / --no-log Stream baker output to terminal [default: --log]
-h, --help This message
HELP
exit 0
}
while [[ $# -gt 0 ]]; do
case "$1" in
-c|--category)
CATEGORY="$2"; shift 2 ;;
--network)
NETWORK=1; shift ;;
-k)
PYTEST_ARGS+=(-k "$2"); shift 2 ;;
--timeout)
PYTEST_ARGS+=(--timeout "$2"); shift 2 ;;
--log) BAKER_LOG=1; shift ;;
--no-log) BAKER_LOG=0; shift ;;
manual) MODE="manual"; shift ;;
auto) MODE="auto"; shift ;;
-h|--help)
show_help ;;
--)
shift; PYTEST_ARGS+=("$@"); break ;;
*)
PYTEST_ARGS+=("$1"); shift ;;
esac
done
# ── resolve category → test dir + image ─────────────────────────────
case "$CATEGORY" in
baker)
TEST_DIR="tests/baker"
TEST_IMAGE="hbw-system-test:latest"
;;
upm:rust)
TEST_DIR="tests/upm"
TEST_IMAGE="hbw-system-test:latest"
;;
pm:pacman|pm:arch)
TEST_DIR="tests/engine"
TEST_IMAGE="hbw-test-pm:test-arch"
;;
pm:apt)
TEST_DIR="tests/engine"
TEST_IMAGE="hbw-test-pm:test-apt"
;;
pm:dnf)
TEST_DIR="tests/engine"
TEST_IMAGE="hbw-test-pm:test-dnf"
;;
pm:apk)
TEST_DIR="tests/engine"
TEST_IMAGE="hbw-test-pm:test-apk"
;;
*)
echo "Unknown category: $CATEGORY" >&2
echo "Run './runner.sh --help' for usage." >&2
exit 1
;;
esac
# ── env vars ──────────────────────────────────────────────────────
DOCKER_ENV=()
DOCKER_ENV+=(-e RUST_LOG=${RUST_LOG:-info})
DOCKER_ENV+=(-e BAKER_LOG=${BAKER_LOG:-1})
# Load llm.env if present (for LLM-based test evaluation)
if [ -f "$SCRIPT_DIR/llm.env" ]; then
while IFS='=' read -r key value; do
[ -z "$key" ] && continue
[[ "$key" =~ ^# ]] && continue
DOCKER_ENV+=(-e "$key=$value")
done < "$SCRIPT_DIR/llm.env"
fi
if [ "$NETWORK" -eq 1 ]; then
DOCKER_ENV+=(-e RUST_UPM_NETWORK=1)
fi
# ── cleanup ─────────────────────────────────────────────────────────
cleanup() {
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
[ "$TEST_DIR" = "tests/baker" ] && rm -rf "$STAGING_DIR"
}
trap cleanup EXIT
# ── build baker binary ──────────────────────────────────────────────
build_binary() {
local target="x86_64-unknown-linux-musl"
local binary="$SCRIPT_DIR/workshop-baker/target/$target/debug/workshop-baker"
if [ ! -f "$binary" ]; then
echo "[runner] Building static (musl) baker binary..."
cd "$SCRIPT_DIR/workshop-baker"
cargo build -p workshop-baker --target "$target" 2>&1 | tail -n 1
fi
}
# ── staging ─────────────────────────────────────────────────────────
prepare_staging() {
# Only needed for baker tests (hbw-system-test:latest mounts /workshop)
if [ "$TEST_DIR" = "tests/baker" ]; then
if [ "$TEST_DIR" != "tests/engine" ]; then
mkdir -p "$STAGING_DIR"
cp "$SCRIPT_DIR/workshop-baker/target/x86_64-unknown-linux-musl/debug/workshop-baker" "$STAGING_DIR/"
chmod +x "$STAGING_DIR/workshop-baker"
fi
}
run_container() {
local tag="${TEST_IMAGE##*:}"
local filter=""
case "$tag" in
test-arch) filter="-k pacman" ;;
test-apt) filter="-k apt" ;;
test-dnf) filter="-k dnf" ;;
test-apk) filter="-k apk" ;;
# ── pm filter ───────────────────────────────────────────────────────
pm_filter() {
case "${TEST_IMAGE##*:}" in
test-arch) echo "-k pacman" ;;
test-apt) echo "-k apt" ;;
test-dnf) echo "-k dnf" ;;
test-apk) echo "-k apk" ;;
*) echo "" ;;
esac
}
echo "[runner] Starting: $TEST_IMAGE (tests=$TEST_DIR${filter:+, filter=$filter})"
mkdir -p "$SCRIPT_DIR/tests/results"
# ── run ─────────────────────────────────────────────────────────────
local mounts="-v $SCRIPT_DIR:/src:ro"
local entrypoint=""
if [ "$TEST_DIR" = "tests/baker" ]; then
mounts="$mounts -v $STAGING_DIR:/workshop:ro"
entrypoint='--entrypoint ""'
run_container() {
local filter=""
if [ "$TEST_DIR" = "tests/engine" ]; then
filter="$(pm_filter)"
fi
local cmd="sh -c \"python3 -m venv /tmp/v && . /tmp/v/bin/activate && \
pip install pytest pytest-timeout -q && \
exec python -m pytest $TEST_DIR $PYTEST_ARGS $filter\""
echo "============================================"
echo " HBW System Test Runner"
echo " Category: $TEST_DIR Image: $TEST_IMAGE"
echo "============================================"
mkdir -p "$SCRIPT_DIR/tests/results"
local mounts=(-v "$SCRIPT_DIR:/src:ro")
if [ "$TEST_DIR" != "tests/engine" ]; then
mounts+=(-v "$STAGING_DIR:/workshop") # writable: baker writes temp scripts here
fi
local pytest_cmd="python3 -m venv /tmp/v && . /tmp/v/bin/activate && \
pip install -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple \
-r $TEST_DIR/../requirements.txt -q && \
exec python -m pytest -v -s $TEST_DIR ${PYTEST_ARGS[*]} $filter"
if [ "$MODE" = "manual" ]; then
docker run -it --rm --name "$CONTAINER_NAME" --privileged $entrypoint \
$mounts "$TEST_IMAGE"
docker run -it --rm --name "$CONTAINER_NAME" --privileged \
${DOCKER_ENV[@]} "${mounts[@]}" "$TEST_IMAGE"
else
docker run --rm --name "$CONTAINER_NAME" --privileged $entrypoint \
$mounts -w /src \
"$TEST_IMAGE" "$cmd"
# Baker/UPM images (hbw-system-test) have ./entrypoint.sh which
# doesn't exist when /src is the workdir → override to empty.
# PM images (hbw-test-pm:*) have /bin/sh -c → CMD passed directly.
if [ "$TEST_DIR" = "tests/engine" ]; then
docker run --rm --name "$CONTAINER_NAME" --privileged \
${DOCKER_ENV[@]} "${mounts[@]}" -w /src \
"$TEST_IMAGE" "$pytest_cmd"
else
docker run --rm --name "$CONTAINER_NAME" --privileged \
--entrypoint "" ${DOCKER_ENV[@]} "${mounts[@]}" -w /src \
"$TEST_IMAGE" /bin/sh -c "$pytest_cmd"
fi
fi
}
echo "============================================"
echo " HBW System Test Runner"
echo " Image: $TEST_IMAGE"
echo " Tests: $TEST_DIR"
echo "============================================"
# ── main ────────────────────────────────────────────────────────────
build_binary
prepare_staging
run_container
+6 -30
View File
@@ -1,16 +1,14 @@
import os
import pytest
FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures", "bake")
FIXTURES = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures", "bake")
@pytest.mark.bake
class TestBakeSimpleScript:
def test_simple_script_runs(self, run_baker, bake_workspace, work_dir):
prebake_cfg = os.path.join(
os.path.dirname(__file__), "fixtures", "prebake", "minimal.yml"
os.path.dirname(os.path.dirname(__file__)), "fixtures", "prebake", "minimal.yml"
)
script = os.path.join(FIXTURES, "simple.sh")
bake_base = os.path.join(FIXTURES, "bake_base.sh")
@@ -27,7 +25,7 @@ class TestBakeSimpleScript:
def test_simple_script_sets_env_vars(self, run_baker, bake_workspace, work_dir):
prebake_cfg = os.path.join(
os.path.dirname(__file__), "fixtures", "prebake", "minimal.yml"
os.path.dirname(os.path.dirname(__file__)), "fixtures", "prebake", "minimal.yml"
)
script = os.path.join(FIXTURES, "simple.sh")
bake_base = os.path.join(FIXTURES, "bake_base.sh")
@@ -42,13 +40,12 @@ class TestBakeSimpleScript:
assert result["success"]
assert "test-pipeline" in result["stdout"]
@pytest.mark.bake
class TestBakePipeline:
def test_pipeline_functions_execute(self, run_baker, bake_workspace, work_dir):
prebake_cfg = os.path.join(
os.path.dirname(__file__), "fixtures", "prebake", "minimal.yml"
os.path.dirname(os.path.dirname(__file__)), "fixtures", "prebake", "minimal.yml"
)
script = os.path.join(FIXTURES, "pipeline.sh")
bake_base = os.path.join(FIXTURES, "bake_base.sh")
@@ -66,7 +63,7 @@ class TestBakePipeline:
def test_pipeline_step_outputs_correct(self, run_baker, bake_workspace, work_dir):
prebake_cfg = os.path.join(
os.path.dirname(__file__), "fixtures", "prebake", "minimal.yml"
os.path.dirname(os.path.dirname(__file__)), "fixtures", "prebake", "minimal.yml"
)
script = os.path.join(FIXTURES, "pipeline.sh")
bake_base = os.path.join(FIXTURES, "bake_base.sh")
@@ -84,13 +81,12 @@ class TestBakePipeline:
with open("/tmp/bake-test-output/step2.txt") as f:
assert f.read().strip() == "step2-done"
@pytest.mark.bake
class TestBakeAfterDependencies:
def test_after_ordering(self, run_baker, bake_workspace, work_dir):
prebake_cfg = os.path.join(
os.path.dirname(__file__), "fixtures", "prebake", "minimal.yml"
os.path.dirname(os.path.dirname(__file__)), "fixtures", "prebake", "minimal.yml"
)
script = os.path.join(FIXTURES, "with_after.sh")
bake_base = os.path.join(FIXTURES, "bake_base.sh")
@@ -107,23 +103,3 @@ class TestBakeAfterDependencies:
assert os.path.exists("/tmp/bake-after-test/source.txt")
assert os.path.exists("/tmp/bake-after-test/build.txt")
@pytest.mark.bake
class TestBakeStatusFiles:
def test_status_written_after_bake(self, run_baker, bake_workspace, work_dir):
prebake_cfg = os.path.join(
os.path.dirname(__file__), "fixtures", "prebake", "minimal.yml"
)
script = os.path.join(FIXTURES, "simple.sh")
bake_base = os.path.join(FIXTURES, "bake_base.sh")
result = run_baker(
"bake", script,
"--bake-base", bake_base,
"--prebake", prebake_cfg,
cwd=work_dir,
timeout=60,
)
assert result["success"]
assert os.path.exists("/tmp/.hbwstatus")
+3 -18
View File
@@ -4,9 +4,7 @@ import pytest
from probes import file
from llm import judge_log, LLMClient
FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures", "finalize")
FIXTURES = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures", "finalize")
@pytest.mark.finalize
class TestFinalizeMinimal:
@@ -16,7 +14,6 @@ class TestFinalizeMinimal:
result = run_baker("finalize", config, cwd=work_dir, timeout=60)
assert result["success"], f"Minimal finalize failed: {result['stderr']}"
@pytest.mark.finalize
class TestFinalizeHooks:
@@ -32,17 +29,6 @@ class TestFinalizeHooks:
assert result["success"]
assert "finalize late hook" in result["stdout"] or "finalize late hook" in result["stderr"]
@pytest.mark.finalize
class TestFinalizeStatus:
def test_finalize_writes_status(self, run_baker, work_dir):
config = os.path.join(FIXTURES, "minimal.yml")
result = run_baker("finalize", config, cwd=work_dir, timeout=60)
assert result["success"]
assert os.path.exists("/tmp/.hbwstatus")
@pytest.mark.finalize
@pytest.mark.llm
class TestFinalizeWithLLM:
@@ -64,14 +50,13 @@ class TestFinalizeWithLLM:
)
assert judgment.passed, f"LLM judgment failed: {judgment.reason}"
@pytest.mark.finalize
class TestFinalizePlugins:
@pytest.mark.xfail(reason="Plugin download requires network/git access", strict=False)
@pytest.mark.skip(reason="Plugin download test not yet implemented")
def test_plugin_download(self, run_baker, work_dir):
pass
@pytest.mark.xfail(reason="Artifact stage not yet implemented", strict=False)
@pytest.mark.skip(reason="Artifact stage not yet implemented")
def test_artifact_collection(self, run_baker, work_dir):
pass
+3 -62
View File
@@ -4,9 +4,7 @@ import pytest
from probes import user, file, package
FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures", "prebake")
FIXTURES = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures", "prebake")
@pytest.mark.prebake
class TestPrebakeConfigValidation:
@@ -29,7 +27,6 @@ class TestPrebakeConfigValidation:
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
assert result["success"], f"Minimal prebake failed: {result['stderr']}"
@pytest.mark.prebake
class TestPrebakeBootstrap:
@@ -89,7 +86,6 @@ class TestPrebakeBootstrap:
assert result["success"], f"Custom bootstrap failed: {result['stderr']}"
assert file.dir_exists("/custom-workspace")
@pytest.mark.prebake
class TestPrebakeDryRun:
@@ -107,7 +103,6 @@ class TestPrebakeDryRun:
combined = result["stdout"] + result["stderr"]
assert "DRY_RUN" in combined or "dry_run" in combined.lower()
@pytest.mark.prebake
class TestPrebakeHooks:
@@ -143,17 +138,15 @@ class TestPrebakeHooks:
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
assert result["success"], f"prebake with no hooks failed: {result['stderr']}"
@pytest.mark.prebake
class TestPrebakeDepsSystem:
@pytest.mark.xfail(reason="Package installation may fail in test environment", strict=False)
def test_pacman_packages_installed(self, run_baker, work_dir):
config = os.path.join(FIXTURES, "with_pacman.yml")
result = run_baker("prebake", config, cwd=work_dir, timeout=120)
assert result["success"], f"prebake failed: {result['stderr']}"
assert package.package_installed("curl")
assert package.package_installed("wget")
assert package.binary_exists("/usr/bin/curl")
assert package.binary_exists("/usr/bin/wget")
def test_pacman_mirror_change(self, run_baker, work_dir):
mirror_config = os.path.join(work_dir, "mirror.yml")
@@ -173,7 +166,6 @@ dependencies:
result = run_baker("prebake", mirror_config, cwd=work_dir, timeout=120)
assert result["success"], f"prebake with mirror failed: {result['stderr']}"
@pytest.mark.prebake
class TestPrebakeEnvVars:
@@ -199,7 +191,6 @@ hooks:
combined = result["stdout"] + result["stderr"]
assert "injected_value" in combined
@pytest.mark.prebake
class TestPrebakeSecurity:
@@ -209,53 +200,3 @@ class TestPrebakeSecurity:
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
assert result["success"], f"prebake with security failed: {result['stderr']}"
@pytest.mark.prebake
class TestPrebakeStatus:
def test_status_files_written(self, run_baker, work_dir):
config = os.path.join(FIXTURES, "bootstrap_only.yml")
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
assert result["success"]
assert os.path.exists("/tmp/.hbwstatus")
def test_status_contains_bootstrap_stage(self, run_baker, work_dir):
config = os.path.join(FIXTURES, "bootstrap_only.yml")
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
assert result["success"]
with open("/tmp/.hbwstatus", "r") as f:
lines = f.readlines()
assert len(lines) > 0
bootstrap_lines = [l for l in lines if json.loads(l).get("name") == "bootstrap"]
assert len(bootstrap_lines) > 0, "No bootstrap stage found in status file"
data = json.loads(bootstrap_lines[-1])
assert data.get("phase") == "Prebake"
assert data.get("result") == "Success"
def test_status_contains_duration(self, run_baker, work_dir):
config = os.path.join(FIXTURES, "bootstrap_only.yml")
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
assert result["success"]
with open("/tmp/.hbwstatus", "r") as f:
data = json.loads(f.readline())
assert "duration_ms" in data
assert isinstance(data["duration_ms"], int)
def test_status_contains_timestamps(self, run_baker, work_dir):
config = os.path.join(FIXTURES, "bootstrap_only.yml")
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
assert result["success"]
with open("/tmp/.hbwstatus", "r") as f:
data = json.loads(f.readline())
assert "started_at" in data
assert "finished_at" in data
def test_status_written_for_each_stage(self, run_baker, work_dir):
config = os.path.join(FIXTURES, "with_pacman.yml")
result = run_baker("prebake", config, cwd=work_dir, timeout=120)
assert result["success"], f"prebake failed: {result['stderr']}"
with open("/tmp/.hbwstatus", "r") as f:
lines = f.readlines()
names = [json.loads(line).get("name") for line in lines]
assert "bootstrap" in names
assert "depssystem" in names
+41 -25
View File
@@ -77,36 +77,52 @@ def run_cmd():
"""
def _run(args, cwd=None, env=None, timeout=120):
"""Run a command and capture output.
Args:
args: Command as list of strings.
cwd: Working directory.
env: Environment variables (merged with os.environ).
timeout: Timeout in seconds.
Returns:
dict with keys: stdout, stderr, returncode, success
"""
full_env = {**os.environ}
if env:
full_env.update(env)
show_logs = os.environ.get("BAKER_LOG", "0") == "1"
try:
result = subprocess.run(
args,
cwd=cwd,
env=full_env,
capture_output=True,
text=True,
timeout=timeout,
)
return {
"stdout": result.stdout,
"stderr": result.stderr,
"returncode": result.returncode,
"success": result.returncode == 0,
}
if show_logs:
# Stream output to terminal in real-time while capturing
import sys, select
p = subprocess.Popen(
args, cwd=cwd, env=full_env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True,
)
stdout_chunks, stderr_chunks = [], []
try:
p.wait(timeout=timeout)
except subprocess.TimeoutExpired:
p.kill()
p.wait()
return {"stdout": "", "stderr": f"Command timed out after {timeout}s",
"returncode": -1, "success": False}
for line in p.stdout:
sys.stderr.write("[baker] " + line)
stdout_chunks.append(line)
for line in p.stderr:
sys.stderr.write("[baker:err] " + line)
stderr_chunks.append(line)
return {
"stdout": "".join(stdout_chunks),
"stderr": "".join(stderr_chunks),
"returncode": p.returncode,
"success": p.returncode == 0,
}
else:
result = subprocess.run(
args, cwd=cwd, env=full_env,
capture_output=True, text=True, timeout=timeout,
)
return {
"stdout": result.stdout,
"stderr": result.stderr,
"returncode": result.returncode,
"success": result.returncode == 0,
}
except subprocess.TimeoutExpired:
return {
"stdout": "",
+1
View File
@@ -9,6 +9,7 @@ bootstrap:
dependencies:
system:
pacman:
mirror: "https://mirrors.tuna.tsinghua.edu.cn/archlinux/$repo/os/$arch"
packages:
- "curl"
- "wget"
+1 -3
View File
@@ -15,9 +15,7 @@ class LLMCache:
def __init__(self, cache_dir: str | None = None):
if cache_dir is None:
import os
cache_dir = os.path.join(os.path.dirname(__file__), ".cache")
cache_dir = "/tmp/llm-cache"
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(parents=True, exist_ok=True)
View File
+150
View File
@@ -0,0 +1,150 @@
"""Go UPM system tests — go install + go mod download via bare prebake."""
import os
import subprocess
import pytest
from probes import binary_exists
def _yml(actions_yaml):
return """\
version: "1.0"
environment:
builder: "baremetal"
bootstrap:
user: "vulcan"
security:
drop_after: never
dependencies:
config:
repology_endpoint: none
system:
pacman:
packages:
user:
go:
""" + _indent(actions_yaml, 6)
def _indent(text, spaces):
pad = " " * spaces
return "\n".join(pad + line if line.strip() else line
for line in text.splitlines())
def _query(cmd, timeout=60):
try:
r = subprocess.run(cmd, capture_output=True, text=True,
timeout=timeout, env={**os.environ, "HOME": "/root"})
return {"success": r.returncode == 0, "stdout": r.stdout, "stderr": r.stderr}
except Exception as e:
return {"success": False, "stdout": "", "stderr": str(e)}
def _run_prebake(run_baker, config_path, timeout, operation, expected):
r = run_baker("prebake", config_path, timeout=timeout,
env={"RUST_LOG": os.environ.get("RUST_LOG", "info")})
full_log = "[stdout]\n{}\n[stderr]\n{}".format(r["stdout"], r["stderr"])
return r, full_log
def _check(r, full_log, operation, expected):
if r["success"]:
return
diagnosis = ""
try:
from llm import judge_log
j = judge_log(operation, expected, full_log)
diagnosis = "\n LLM diagnosis: [{}] {}".format(j.verdict, j.reason)
except Exception:
pass
pytest.fail("{} failed ({} chars):\n{}\n{}".format(
operation, len(full_log), full_log, diagnosis))
def _gopath_bin(name):
"""Return the expected $GOPATH/bin path or $HOME/go/bin fallback."""
gopath = os.environ.get("GOPATH", "")
home = os.environ.get("HOME", "/root")
if not gopath:
gopath = os.path.join(home, "go")
return os.path.join(gopath, "bin", name)
# ── go install ──────────────────────────────────────────────────────
def test_go_install(run_baker, work_dir):
"""go install a small known tool, verify binary exists."""
cfg = os.path.join(work_dir, "go.yml")
with open(cfg, "w") as f:
f.write(_yml("""\
proxy: "https://goproxy.cn,direct"
packages: ["golang.org/x/tools/cmd/goimports@latest"]
"""))
r, log = _run_prebake(run_baker, cfg, timeout=300,
operation="go install goimports",
expected="goimports binary appears in GOPATH/bin")
_check(r, log, "go install", "goimports installed")
bin_path = _gopath_bin("goimports")
assert binary_exists(bin_path), f"goimports not found at {bin_path}"
# ── go mod download ─────────────────────────────────────────────────
def test_go_mod_download(run_baker, work_dir):
"""Create a minimal go.mod, download deps, verify cache populated."""
mod_dir = os.path.join(work_dir, "testmod")
os.makedirs(mod_dir, exist_ok=True)
with open(os.path.join(mod_dir, "go.mod"), "w") as f:
f.write("module test\n\ngo 1.21\n\nrequire golang.org/x/text v0.14.0\n")
cfg = os.path.join(work_dir, "go.yml")
with open(cfg, "w") as f:
f.write(_yml("""\
proxy: "https://goproxy.cn,direct"
modules: ["{mod}"]
""".format(mod=os.path.join(mod_dir, "go.mod"))))
r, log = _run_prebake(run_baker, cfg, timeout=180,
operation="go mod download",
expected="GOMODCACHE has x/text cached")
_check(r, log, "go mod download", "dependencies downloaded to module cache")
# Check GOMODCACHE or default $GOPATH/pkg/mod
gomodcache = os.environ.get("GOMODCACHE", "")
if not gomodcache:
gopath = os.environ.get("GOPATH",
os.path.join(os.environ.get("HOME", "/root"), "go"))
gomodcache = os.path.join(gopath, "pkg", "mod")
cache_dir = os.path.join(gomodcache, "golang.org", "x")
assert os.path.isdir(cache_dir), f"no module cache at {cache_dir}"
# ── go install + mod download ───────────────────────────────────────
def test_go_full(run_baker, work_dir):
"""go install a tool AND download project deps."""
mod_dir = os.path.join(work_dir, "testmod2")
os.makedirs(mod_dir, exist_ok=True)
with open(os.path.join(mod_dir, "go.mod"), "w") as f:
f.write("module test\n\ngo 1.21\n")
cfg = os.path.join(work_dir, "go.yml")
with open(cfg, "w") as f:
f.write(_yml("""\
proxy: "https://goproxy.cn,direct"
packages: ["golang.org/x/tools/cmd/goimports@latest"]
modules: ["{mod}"]
env:
GOMODCACHE: "/tmp/gocache"
""".format(mod=os.path.join(mod_dir, "go.mod"))))
r, log = _run_prebake(run_baker, cfg, timeout=300,
operation="go install + go mod download",
expected="goimports installed, module cache populated")
_check(r, log, "go full", "tool installed + deps downloaded")
assert binary_exists(_gopath_bin("goimports")), "goimports missing"
assert os.path.isdir("/tmp/gocache"), "GOMODCACHE not created"
+163
View File
@@ -0,0 +1,163 @@
"""Python UPM system tests — pip, uv, poetry, pipx via bare prebake."""
import os
import pytest
from probes import binary_exists
RUST_LOG = os.environ.get("RUST_LOG", "info")
def _yml(actions_yaml):
return """\
version: "1.0"
environment:
builder: "baremetal"
bootstrap:
user: "vulcan"
security:
drop_after: never
dependencies:
config:
repology_endpoint: none
system:
pacman:
packages:
user:
python:
""" + _indent(actions_yaml, 6)
def _indent(text, spaces):
pad = " " * spaces
return "\n".join(pad + line if line.strip() else line
for line in text.splitlines())
def _query(cmd, timeout=60):
import subprocess
try:
r = subprocess.run(cmd, capture_output=True, text=True,
timeout=timeout, env={**os.environ, "HOME": "/root"})
return {"success": r.returncode == 0, "stdout": r.stdout, "stderr": r.stderr}
except Exception as e:
return {"success": False, "stdout": "", "stderr": str(e)}
def _run_prebake(run_baker, config_path, timeout, operation, expected):
env_vars = {"RUST_LOG": RUST_LOG}
r = run_baker("prebake", config_path, timeout=timeout, env=env_vars)
full_log = "[stdout]\n{}\n[stderr]\n{}".format(r["stdout"], r["stderr"])
return r, full_log
def _check(r, full_log, operation, expected):
if r["success"]:
return
diagnosis = ""
try:
from llm import judge_log
j = judge_log(operation, expected, full_log)
diagnosis = "\n LLM diagnosis: [{}] {}".format(j.verdict, j.reason)
except Exception:
pass
pytest.fail("{} failed ({} chars):\n{}\n{}".format(
operation, len(full_log), full_log, diagnosis))
# ── pip ─────────────────────────────────────────────────────────────
def test_pip_install_package(run_baker, work_dir):
"""Install a package via pip."""
cfg = os.path.join(work_dir, "python.yml")
with open(cfg, "w") as f:
f.write(_yml("""\
- type: pip
packages: [requests]
index: "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
args: ["--break-system-packages"]
"""))
r, log = _run_prebake(run_baker, cfg, timeout=120,
operation="pip install requests", expected="requests module importable")
_check(r, log, "pip install", "pip installs requests successfully")
r2 = _query(["python3", "-c", "import requests"])
assert r2["success"], f"import requests failed: {r2['stderr']}"
# ── pip + venv ──────────────────────────────────────────────────────
def test_pip_venv_install(run_baker, work_dir):
"""Create venv and install inside it."""
cfg = os.path.join(work_dir, "python.yml")
with open(cfg, "w") as f:
f.write(_yml("""\
- type: pip
directory: "/tmp/test-venv"
index: "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
packages: [requests]
"""))
r, log = _run_prebake(run_baker, cfg, timeout=120,
operation="venv + pip install",
expected="requests installed inside venv")
_check(r, log, "pip venv install", "venv created, requests installed")
r2 = _query(["/tmp/test-venv/bin/python3", "-c", "import requests"])
assert r2["success"], f"import from venv failed: {r2['stderr']}"
# ── pip + requirements ──────────────────────────────────────────────
def test_pip_requirements_file(run_baker, work_dir):
"""Install from a requirements.txt file."""
req_path = os.path.join(work_dir, "requirements.txt")
with open(req_path, "w") as f:
f.write("requests\n")
cfg = os.path.join(work_dir, "python.yml")
with open(cfg, "w") as f:
f.write(_yml("""\
- type: pip
directory: "/tmp/test-venv2"
index: "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
manifest: "{}"
""".format(req_path)))
r, log = _run_prebake(run_baker, cfg, timeout=120,
operation="pip install -r requirements.txt",
expected="requests installed from requirements file")
_check(r, log, "pip -r requirements.txt", "pip installs from requirements file")
r2 = _query(["/tmp/test-venv2/bin/python3", "-c", "import requests"])
assert r2["success"], f"import from venv failed: {r2['stderr']}"
# ── multiple actions ────────────────────────────────────────────────
def test_multiple_actions(run_baker, work_dir):
"""Two pip actions in one config."""
cfg = os.path.join(work_dir, "python.yml")
with open(cfg, "w") as f:
f.write(_yml("""\
- type: pip
directory: "/tmp/test-venv-a"
index: "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
packages: [requests]
args: ["--break-system-packages"]
- type: pip
directory: "/tmp/test-venv-b"
index: "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
packages: [pytest]
"""))
r, log = _run_prebake(run_baker, cfg, timeout=180,
operation="two pip actions",
expected="both venvs have their packages")
_check(r, log, "multiple pip actions", "both requests and pytest installed")
r2 = _query(["/tmp/test-venv-a/bin/python3", "-c", "import requests"])
assert r2["success"], f"venv-a missing requests: {r2['stderr']}"
r3 = _query(["/tmp/test-venv-b/bin/python3", "-c", "import pytest"])
assert r3["success"], f"venv-b missing pytest: {r3['stderr']}"
+275
View File
@@ -0,0 +1,275 @@
"""Rust UPM system tests driven by bare workshop-baker prebake.
These tests exercise the ``user.rust`` UPM through its full lifecycle:
1. sysdep injection (rustup via pacman, repology_endpoint: none)
2. rustup toolchain / component / target installation
3. cargo fetch (project dependency pre-fetch)
4. cargo install (global crate tools)
Observability
* ``RUST_LOG=info`` is injected into every baker invocation.
* On failure the *full* stdout + stderr is printed (no truncation).
* An LLM judge (``tests/llm/judge.py``) diagnoses failures with a
human-readable reason, cached per-log-hash to save tokens.
Network-dependent tests (toolchain download) are skipped by default.
Set ``RUST_UPM_NETWORK=1`` to run them.
"""
import os
import subprocess
import pytest
from probes import binary_exists
# ── helpers ─────────────────────────────────────────────────────────
RUST_LOG = os.environ.get("RUST_LOG", "info")
def _yml(user_section):
"""Build a minimal prebake.yml focused on the rust UPM."""
return """\
version: "1.0"
environment:
builder: "baremetal"
bootstrap:
user: "vulcan"
security:
drop_after: never
envvars:
prebake:
RUSTUP_DIST_SERVER: "https://mirrors.tuna.tsinghua.edu.cn/rustup"
dependencies:
config:
repology_endpoint: none
system:
pacman:
packages:
user:
rust:
""" + _indent(user_section, 6)
def _indent(text, spaces):
pad = " " * spaces
return "\n".join(pad + line if line.strip() else line
for line in text.splitlines())
def _cargo_toml(dir_path):
path = os.path.join(dir_path, "Cargo.toml")
with open(path, "w") as f:
f.write('[package]\nname = "test"\nversion = "0.1.0"\nedition = "2021"\n')
return path
def _query(cmd, timeout=60):
try:
r = subprocess.run(cmd, capture_output=True, text=True,
timeout=timeout, env={**os.environ, "HOME": "/root"})
return {"success": r.returncode == 0, "stdout": r.stdout,
"stderr": r.stderr}
except Exception as e:
return {"success": False, "stdout": "", "stderr": str(e)}
def _run_prebake(run_baker, config_path, timeout, operation, expected):
"""Run prebake with RUST_LOG, return (result, full_log)."""
env_vars = {"RUST_LOG": RUST_LOG}
r = run_baker("prebake", config_path, timeout=timeout, env=env_vars)
full_log = "[stdout]\n{}\n[stderr]\n{}".format(r["stdout"], r["stderr"])
return r, full_log
def _check(r, full_log, operation, expected):
"""Assert success; on failure call LLM judge and show full output."""
if r["success"]:
return
diagnosis = ""
try:
from llm import judge_log
j = judge_log(operation, expected, full_log)
diagnosis = "\n LLM diagnosis: [{}] {}".format(j.verdict, j.reason)
except Exception:
pass
pytest.fail(
"{} failed ({} chars):\n{}\n{}".format(
operation, len(full_log), full_log, diagnosis)
)
# ── system dependency resolution ────────────────────────────────────
def test_repology_none_config(run_baker, work_dir):
cfg = os.path.join(work_dir, "rust.yml")
with open(cfg, "w") as f:
f.write(_yml("""\
cargo: {}
"""))
r, log = _run_prebake(run_baker, cfg, timeout=120,
operation="UPM sysdep: repology_endpoint=none",
expected="rustup gets installed via pacman")
_check(r, log, "repology-none sysdep resolution",
"pacman installs rustup, empty cargo config")
assert binary_exists("/usr/bin/rustup"), "rustup not installed via sysdep"
# ── rustup: toolchain & components ──────────────────────────────────
@pytest.mark.skipif(
"RUST_UPM_NETWORK" not in os.environ,
reason="set RUST_UPM_NETWORK=1 for tests that download toolchain/crates"
)
def test_toolchain_and_component(run_baker, work_dir):
cfg = os.path.join(work_dir, "rust.yml")
with open(cfg, "w") as f:
f.write(_yml("""\
toolchain: "stable"
components: ["rustfmt"]
"""))
r, log = _run_prebake(run_baker, cfg, timeout=600,
operation="rustup default stable + component add rustfmt",
expected="stable toolchain active, rustfmt installed")
_check(r, log, "toolchain + rustfmt installation",
"rustup default stable succeeds; rustfmt available")
assert binary_exists("/usr/bin/rustup"), "rustup not installed"
r2 = _query(["rustup", "which", "rustc"])
assert r2["success"], "rustc not found via rustup which"
assert os.path.isfile(r2["stdout"].strip()), "rustc binary missing"
# ── cross-compilation targets ───────────────────────────────────────
@pytest.mark.skipif(
"RUST_UPM_NETWORK" not in os.environ,
reason="set RUST_UPM_NETWORK=1 for tests that download toolchain/crates"
)
def test_cross_compile_target(run_baker, work_dir):
cfg = os.path.join(work_dir, "rust.yml")
with open(cfg, "w") as f:
f.write(_yml("""\
toolchain: "stable"
targets: ["wasm32-unknown-unknown"]
"""))
r, log = _run_prebake(run_baker, cfg, timeout=600,
operation="rustup target add wasm32-unknown-unknown",
expected="wasm32 target appears in installed list")
_check(r, log, "cross-compilation target installation",
"wasm32-unknown-unknown is installed")
r2 = _query(["rustup", "target", "list", "--installed"])
assert r2["success"], "rustup target list failed"
assert "wasm32-unknown-unknown" in r2["stdout"], "wasm32 target missing"
# ── cargo fetch ─────────────────────────────────────────────────────
@pytest.mark.skipif(
"RUST_UPM_NETWORK" not in os.environ,
reason="set RUST_UPM_NETWORK=1 for tests that download toolchain/crates"
)
def test_cargo_fetch(run_baker, work_dir):
manifest = _cargo_toml(work_dir)
cfg = os.path.join(work_dir, "rust.yml")
with open(cfg, "w") as f:
f.write(_yml("""\
toolchain: "stable"
cargo:
fetch: true
locked: true
manifests: ["{manifest}"]
""".format(manifest=manifest)))
r, log = _run_prebake(run_baker, cfg, timeout=600,
operation="cargo fetch on minimal Cargo.toml",
expected="~/.cargo/registry/cache populated")
_check(r, log, "cargo fetch dependency pre-fetch",
"cargo fetch succeeds, registry cache non-empty")
cache = os.path.expanduser("~/.cargo/registry/cache")
assert os.path.isdir(cache), "registry/cache missing"
assert len(os.listdir(cache)) > 0, "registry/cache empty after fetch"
# ── cargo install ───────────────────────────────────────────────────
@pytest.mark.skipif(
"RUST_UPM_NETWORK" not in os.environ,
reason="set RUST_UPM_NETWORK=1 for tests that download toolchain/crates"
)
def test_cargo_install(run_baker, work_dir):
cfg = os.path.join(work_dir, "rust.yml")
with open(cfg, "w") as f:
f.write(_yml("""\
toolchain: "stable"
cargo:
packages: ["cargo-audit"]
locked: true
"""))
r, log = _run_prebake(run_baker, cfg, timeout=1200,
operation="cargo install cargo-audit",
expected="cargo-audit binary in ~/.cargo/bin/")
_check(r, log, "cargo install crate",
"cargo-audit compiled and installed")
assert binary_exists(
os.path.expanduser("~/.cargo/bin/cargo-audit")
), "cargo-audit not installed"
# ── full flow ───────────────────────────────────────────────────────
@pytest.mark.skipif(
"RUST_UPM_NETWORK" not in os.environ,
reason="set RUST_UPM_NETWORK=1 for tests that download toolchain/crates"
)
def test_full_flow(run_baker, work_dir):
manifest = _cargo_toml(work_dir)
cfg = os.path.join(work_dir, "rust.yml")
with open(cfg, "w") as f:
f.write(_yml("""\
toolchain: "stable"
components: ["rustfmt"]
targets: ["wasm32-unknown-unknown"]
cargo:
fetch: true
locked: true
manifests: ["{manifest}"]
packages: ["cargo-watch"]
""".format(manifest=manifest)))
r, log = _run_prebake(run_baker, cfg, timeout=1200,
operation="full Rust UPM flow",
expected="all stages succeed")
_check(r, log, "full Rust UPM end-to-end",
"toolchain + cross target + cargo fetch + cargo install all pass")
assert binary_exists("/usr/bin/rustup")
r2 = _query(["rustup", "which", "rustc"])
assert r2["success"]
r3 = _query(["rustup", "target", "list", "--installed"])
assert "wasm32-unknown-unknown" in r3["stdout"]
cache = os.path.expanduser("~/.cargo/registry/cache")
assert os.path.isdir(cache) and len(os.listdir(cache)) > 0
assert binary_exists(os.path.expanduser("~/.cargo/bin/cargo-watch"))
+2 -1
View File
@@ -154,7 +154,8 @@ pub async fn prebake(
.map(|c| c.repology_endpoint)
.unwrap_or(workshop_engine::RepologyEndpoint::Default);
let pm_name = dependencies.system
let pm_name = dependencies
.system
.as_ref()
.and_then(|sys| sys.keys().next().cloned());
+1 -1
View File
@@ -64,4 +64,4 @@ pub enum BootstrapError {
#[error("Invalid bootstrap configuration: {0}")]
InvalidConfig(String),
}
}
+12 -7
View File
@@ -57,7 +57,10 @@ pub async fn bootstrap(
match BootstrapSource::parse(&raw) {
BootstrapSource::Inline(script) => {
if ctx.dry_run {
log::info!("[DRY_RUN] User provided inline bootstrap script:\n{}", script);
log::info!(
"[DRY_RUN] User provided inline bootstrap script:\n{}",
script
);
return Ok(());
}
log::debug!("Using inline bootstrap script ({} bytes)", script.len());
@@ -86,13 +89,16 @@ pub async fn bootstrap(
// Should support: checksum verification, retry, timeout.
log::info!("Custom bootstrap from URL: {}", url);
log::info!(" Target: {}", bootstrap_path.display());
unimplemented!("HTTP bootstrap fetching requires bakerd-level network infrastructure");
unimplemented!(
"HTTP bootstrap fetching requires bakerd-level network infrastructure"
);
}
BootstrapSource::File(path) => {
// Local file copy. This is the simplest case — we can implement
// this directly without bakerd infrastructure.
log::info!("Custom bootstrap from local file: {}", path);
tokio::fs::copy(&path, &bootstrap_path).await
tokio::fs::copy(&path, &bootstrap_path)
.await
.map_err(BootstrapError::IOError)?;
}
}
@@ -166,7 +172,8 @@ impl BootstrapSource {
if let Some(name) = raw.strip_prefix("server://") {
return BootstrapSource::Server(name.to_string());
}
if let Some(url) = raw.strip_prefix("https://")
if let Some(url) = raw
.strip_prefix("https://")
.or_else(|| raw.strip_prefix("http://"))
{
return BootstrapSource::Http(url.to_string());
@@ -325,9 +332,7 @@ fn generate_bootstrap(
///
/// Returns `Some(Box<dyn PackageManager>)` if a configured package manager is known,
/// or `None` if no package manager is configured or recognized.
fn get_package_manager(
config: &PrebakeConfig,
) -> Option<Box<dyn pm::PackageManager>> {
fn get_package_manager(config: &PrebakeConfig) -> Option<Box<dyn pm::PackageManager>> {
config
.dependencies
.as_ref()
+2 -2
View File
@@ -57,7 +57,7 @@ pub async fn hook(
ctx: &ExecutionContext,
event_tx: EventSender,
) -> Result<(), ExecutionError> {
dbg!(ctx);
// dbg!(ctx);
let hook = match hook {
Some(h) => h,
None => {
@@ -79,7 +79,7 @@ pub async fn hook(
let result = engine
.execute_command_with_delta(&hook_cmd.command, ctx, &event_tx, &deltactx)
.await;
dbg!(&result);
// dbg!(&result);
if let Err(e) = result {
log::error!("Hook {} failed: {:?}", index, e);
return Err(e);
-3
View File
@@ -14,7 +14,6 @@ pub trait PackageManager {
/// Returns the name of the package manager.
fn name(&self) -> &'static str;
/// Returns the binary name of the package manager.
fn binary(&self) -> &'static str;
@@ -24,7 +23,6 @@ pub trait PackageManager {
/// Generates commands to install the specified packages.
fn install(&self, package: &[String]) -> Vec<Command>;
/// Generates commands to change the primary mirror repository.
fn change_mirror(&self, repo: &str, osinfo: Option<&Info>) -> Vec<Command>;
@@ -42,7 +40,6 @@ pub fn all_managers() -> Vec<Box<dyn PackageManager>> {
]
}
pub fn select(manager: &str) -> Option<Box<dyn PackageManager>> {
all_managers().into_iter().find(|pm| pm.name() == manager)
}
+6 -13
View File
@@ -73,10 +73,8 @@ impl PackageManager for Apk {
// Download and trust GPG key if provided
if let Some(key_url) = &repo_cfg.key_url {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(format!(
"wget -qO- '{}' | apk keys --stdin",
key_url
));
cmd.arg("-c")
.arg(format!("wget -qO- '{}' | apk keys --stdin", key_url));
cmds.push(cmd);
}
@@ -87,10 +85,8 @@ impl PackageManager for Apk {
format!("{}\n", repo_cfg.url)
};
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(format!(
"echo '{}' >> /etc/apk/repositories",
line.trim()
));
cmd.arg("-c")
.arg(format!("echo '{}' >> /etc/apk/repositories", line.trim()));
cmds.push(cmd);
}
@@ -206,9 +202,7 @@ mod tests {
),
(
serde_yaml::Value::String("key_url".to_string()),
serde_yaml::Value::String(
"https://example.com/alpine-key".to_string(),
),
serde_yaml::Value::String("https://example.com/alpine-key".to_string()),
),
]
.into_iter()
@@ -238,8 +232,7 @@ mod tests {
(
serde_yaml::Value::String("url".to_string()),
serde_yaml::Value::String(
"https://dl-cdn.alpinelinux.org/alpine/v3.19/community"
.to_string(),
"https://dl-cdn.alpinelinux.org/alpine/v3.19/community".to_string(),
),
),
(
+13 -17
View File
@@ -209,9 +209,7 @@ fn extract_codename(info: Option<&Info>) -> Option<String> {
// 2. Try to infer from version string
match info.version() {
Version::Custom(v)
if !v.chars().next().map(|c| c.is_numeric()).unwrap_or(true)
=> {
Version::Custom(v) if !v.chars().next().map(|c| c.is_numeric()).unwrap_or(true) => {
// Some systems put codename in Custom field
return Some(v.clone());
}
@@ -296,9 +294,7 @@ mod tests {
[
(
serde_yaml::Value::String("url".to_string()),
serde_yaml::Value::String(
"https://repo.example.com/ubuntu".to_string(),
),
serde_yaml::Value::String("https://repo.example.com/ubuntu".to_string()),
),
(
serde_yaml::Value::String("distro".to_string()),
@@ -340,9 +336,7 @@ mod tests {
[
(
serde_yaml::Value::String("url".to_string()),
serde_yaml::Value::String(
"https://repo.example.com/ubuntu".to_string(),
),
serde_yaml::Value::String("https://repo.example.com/ubuntu".to_string()),
),
(
serde_yaml::Value::String("distro".to_string()),
@@ -350,9 +344,7 @@ mod tests {
),
(
serde_yaml::Value::String("key_url".to_string()),
serde_yaml::Value::String(
"https://repo.example.com/key.gpg".to_string(),
),
serde_yaml::Value::String("https://repo.example.com/key.gpg".to_string()),
),
]
.into_iter()
@@ -377,8 +369,14 @@ mod tests {
#[test]
fn test_sanitize_repo_name() {
assert_eq!(sanitize_repo_name("https://example.com/repo"), "example.com_repo");
assert_eq!(sanitize_repo_name("http://mirror.tuna.tsinghua.edu.cn/ubuntu"), "mirror.tuna.tsinghua.edu.cn_ubuntu");
assert_eq!(
sanitize_repo_name("https://example.com/repo"),
"example.com_repo"
);
assert_eq!(
sanitize_repo_name("http://mirror.tuna.tsinghua.edu.cn/ubuntu"),
"mirror.tuna.tsinghua.edu.cn_ubuntu"
);
assert_eq!(sanitize_repo_name("ppa:user/name"), "user_name");
}
@@ -401,9 +399,7 @@ mod tests {
serde_yaml::Value::Mapping(
[(
serde_yaml::Value::String("url".to_string()),
serde_yaml::Value::String(
"https://repo.example.com/ubuntu".to_string(),
),
serde_yaml::Value::String("https://repo.example.com/ubuntu".to_string()),
)]
.into_iter()
.collect(),
+8 -14
View File
@@ -96,17 +96,17 @@ impl PackageManager for Dnf {
// Download and import the GPG key
let mut key_cmd = Command::new("sh");
key_cmd.arg("-c").arg(format!(
"rpm --import '{}'",
gpgkey
));
key_cmd.arg("-c").arg(format!("rpm --import '{}'", gpgkey));
cmds.push(key_cmd);
} else {
content.push_str("gpgcheck=0\n");
}
// Write the .repo file
let repo_path = format!("/etc/yum.repos.d/{}.repo", sanitize_repo_name(&repo_cfg.name));
let repo_path = format!(
"/etc/yum.repos.d/{}.repo",
sanitize_repo_name(&repo_cfg.name)
);
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(format!(
"cat > '{}' << 'DNF_EOF'\n{}DNF_EOF",
@@ -200,9 +200,7 @@ mod tests {
),
(
serde_yaml::Value::String("baseurl".to_string()),
serde_yaml::Value::String(
"https://repo.example.com/fedora".to_string(),
),
serde_yaml::Value::String("https://repo.example.com/fedora".to_string()),
),
]
.into_iter()
@@ -236,15 +234,11 @@ mod tests {
),
(
serde_yaml::Value::String("baseurl".to_string()),
serde_yaml::Value::String(
"https://repo.example.com/fedora".to_string(),
),
serde_yaml::Value::String("https://repo.example.com/fedora".to_string()),
),
(
serde_yaml::Value::String("gpgkey".to_string()),
serde_yaml::Value::String(
"https://repo.example.com/gpg".to_string(),
),
serde_yaml::Value::String("https://repo.example.com/gpg".to_string()),
),
]
.into_iter()
-2
View File
@@ -77,7 +77,6 @@ impl PackageManager for Pacman {
"pacman"
}
fn binary(&self) -> &'static str {
"pacman"
}
@@ -198,7 +197,6 @@ mod tests {
assert_eq!(pacman.binary(), "pacman");
}
#[test]
fn test_update_command_contents() {
let cmds = Pacman.update();
+1 -1
View File
@@ -20,7 +20,7 @@ pub struct ExecutionContext {
pub standalone: bool,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct DeltaExecutionContext {
pub working_dir: Option<PathBuf>,
pub env_vars: HashMap<String, Option<String>>,
+9 -1
View File
@@ -2,6 +2,9 @@ use crate::types::EventSender;
use async_trait::async_trait;
use serde_yaml::Value;
mod custom;
mod go;
mod python;
mod rust;
use crate::error::DependencyError;
use crate::types::ExecutionContext;
@@ -44,7 +47,12 @@ pub trait UserPackageManager {
/// Returns a list of all available user package managers.
pub fn all_managers() -> Vec<Box<dyn UserPackageManager>> {
vec![Box::new(custom::Custom)]
vec![
Box::new(rust::Rust),
Box::new(python::Python),
Box::new(go::Go),
Box::new(custom::Custom),
]
}
/// Selects a user package manager by name.
+175
View File
@@ -0,0 +1,175 @@
use async_trait::async_trait;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
use crate::error::DependencyError;
use crate::types::{DeltaExecutionContext, Engine, EventSender, ExecutionContext};
use crate::upm::UPMSysDeps;
use super::UserPackageManager;
#[derive(Clone)]
pub struct Go;
fn default_go_type() -> String {
"go".to_string()
}
// ── GoConfig ────────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
struct GoConfig {
/// UPM backend type (currently only `"go"`).
#[serde(default = "default_go_type", rename = "type")]
type_: String,
/// Go module proxy URL (sets `GOPROXY`).
#[serde(default)]
proxy: Option<String>,
/// Packages to install via `go install`.
/// Use `@version` suffix, e.g. `golang.org/x/tools/cmd/goimports@latest`.
#[serde(default)]
packages: Vec<String>,
/// `go.mod` paths for `go mod download`.
/// Each path's directory is `cd`'d into before downloading.
#[serde(default)]
modules: Vec<PathBuf>,
/// Environment variables injected into child processes.
#[serde(default, rename = "env")]
env_: HashMap<String, Option<String>>,
}
// ── UserPackageManager impl ─────────────────────────────────────────
#[async_trait]
impl UserPackageManager for Go {
fn name(&self) -> &'static str {
"go"
}
fn system_dependency(&self, _config: &serde_yaml::Value) -> UPMSysDeps {
UPMSysDeps::new(vec!["go".to_string()], false)
}
async fn main(
&self,
config: &serde_yaml::Value,
ctx: &ExecutionContext,
event_tx: &EventSender,
) -> Result<(), DependencyError> {
let cfg: GoConfig = serde_yaml::from_value(config.clone()).map_err(|e| {
log::error!("Failed to parse user.go config: {}", e);
DependencyError::ParseError()
})?;
let engine = Engine::new();
let mut delta_env = cfg.env_.clone();
// Inject GOPROXY if configured
if let Some(ref proxy) = cfg.proxy {
delta_env.insert("GOPROXY".into(), Some(proxy.clone()));
}
let delta = DeltaExecutionContext {
env_vars: delta_env,
..Default::default()
};
// ── go mod download ──────────────────────────────────────
for mod_path in &cfg.modules {
let dir = mod_path.parent().unwrap_or(mod_path);
let cmd = "go mod download".to_string();
log::info!("Go UPM executing: cd {} && {}", dir.display(), cmd);
let cd_delta = DeltaExecutionContext {
working_dir: Some(dir.to_path_buf()),
env_vars: delta.env_vars.clone(),
..Default::default()
};
let result = engine
.execute_command_with_delta(&cmd, ctx, event_tx, &cd_delta)
.await;
if let Err(e) = result {
log::error!("go mod download failed in {}: {}", dir.display(), e);
return Err(DependencyError::CustomFailed(e));
}
}
// ── go install ──────────────────────────────────────────
if !cfg.packages.is_empty() {
let cmd = format!("go install {}", cfg.packages.join(" "));
log::info!("Go UPM executing: {}", cmd);
let result = engine
.execute_command_with_delta(&cmd, ctx, event_tx, &delta)
.await;
if let Err(e) = result {
log::error!("Go install failed: {}", e);
return Err(DependencyError::CustomFailed(e));
}
}
if cfg.modules.is_empty() && cfg.packages.is_empty() {
log::warn!("Go UPM configured with nothing to do");
}
Ok(())
}
}
// ── Tests ──────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_name() {
assert_eq!(Go.name(), "go");
}
#[test]
fn test_system_dependency() {
let deps = Go.system_dependency(&serde_yaml::Value::Null);
assert_eq!(deps.packages, vec!["go"]);
assert!(!deps.mapped);
}
#[test]
fn test_parse_minimal() {
let yaml = r#"
packages: ["golang.org/x/tools/cmd/goimports@latest"]
"#;
let cfg: GoConfig = serde_yaml::from_str(yaml).unwrap();
assert!(cfg.proxy.is_none());
assert_eq!(cfg.packages.len(), 1);
}
#[test]
fn test_parse_full() {
let yaml = r#"
proxy: "https://goproxy.cn,direct"
packages: ["honnef.co/go/tools/cmd/staticcheck@latest"]
modules:
- "/workspace/server/go.mod"
env:
GOMODCACHE: "/tmp/gocache"
"#;
let cfg: GoConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.proxy.as_deref(), Some("https://goproxy.cn,direct"));
assert_eq!(
cfg.packages,
vec!["honnef.co/go/tools/cmd/staticcheck@latest"]
);
assert_eq!(cfg.modules, vec![PathBuf::from("/workspace/server/go.mod")]);
assert_eq!(
cfg.env_.get("GOMODCACHE").and_then(|v| v.as_deref()),
Some("/tmp/gocache"),
);
}
}
+354
View File
@@ -0,0 +1,354 @@
use async_trait::async_trait;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
use crate::error::DependencyError;
use crate::types::{DeltaExecutionContext, Engine, EventSender, ExecutionContext};
use crate::upm::UPMSysDeps;
use super::UserPackageManager;
#[derive(Clone)]
pub struct Python;
fn default_type() -> String {
"pip".to_string()
}
// ── PythonAction (one entry in the list) ────────────────────────────
/// A single Python package-management operation.
///
/// `type` selects the backend: ``pip``, ``uv``, ``poetry``, or ``pipx``.
/// Everything else applies to that backend's invocation.
#[derive(Debug, Clone, Deserialize)]
struct PythonAction {
/// Backend tool name.
#[serde(default = "default_type", rename = "type")]
type_: String,
/// Python interpreter to use (default: system ``python3``).
#[serde(default)]
python: Option<String>,
/// Virtualenv directory — created if set.
#[serde(default)]
directory: Option<PathBuf>,
/// PyPI index URL (``-i`` / ``--index-url``).
#[serde(default)]
index: Option<String>,
/// Package names.
#[serde(default)]
packages: Vec<String>,
/// Requirements file or pyproject.toml.
#[serde(default)]
manifest: Option<PathBuf>,
/// Extra CLI arguments forwarded to the backend.
#[serde(default)]
args: Vec<String>,
/// Environment variables injected into the child process.
#[serde(default, rename = "env")]
env_: HashMap<String, Option<String>>,
}
// ── PythonConfig (top-level, transparent vec) ───────────────────────
#[derive(Debug, Clone, Deserialize)]
#[serde(transparent)]
struct PythonConfig(Vec<PythonAction>);
// ── helpers ─────────────────────────────────────────────────────────
fn python_bin(action: &PythonAction) -> &str {
action.python.as_deref().unwrap_or("python3")
}
fn index_flag(action: &PythonAction) -> String {
match &action.index {
Some(idx) => format!(" -i '{}'", idx),
None => String::new(),
}
}
fn env_delta(action: &PythonAction) -> DeltaExecutionContext {
DeltaExecutionContext {
env_vars: action.env_.clone(),
..Default::default()
}
}
fn venv_pip(action: &PythonAction) -> String {
match &action.directory {
Some(dir) => format!("{}/bin/pip", dir.display()),
None => format!("{} -m pip", python_bin(action)),
}
}
// ── UserPackageManager impl ─────────────────────────────────────────
#[async_trait]
impl UserPackageManager for Python {
fn name(&self) -> &'static str {
"python"
}
fn system_dependency(&self, config: &serde_yaml::Value) -> UPMSysDeps {
let mut pkgs = vec!["python3".to_string()];
if let Ok(cfg) = serde_yaml::from_value::<PythonConfig>(config.clone()) {
for a in &cfg.0 {
match a.type_.as_str() {
"uv" => pkgs.push("uv".into()),
"poetry" => pkgs.push("poetry".into()),
"pipx" => pkgs.push("pipx".into()),
_ => {}
}
}
pkgs.sort();
pkgs.dedup();
}
UPMSysDeps::new(pkgs, false)
}
async fn main(
&self,
config: &serde_yaml::Value,
ctx: &ExecutionContext,
event_tx: &EventSender,
) -> Result<(), DependencyError> {
let cfg: PythonConfig = serde_yaml::from_value(config.clone()).map_err(|e| {
log::error!("Failed to parse user.python config: {}", e);
DependencyError::ParseError()
})?;
let engine = Engine::new();
for action in &cfg.0 {
self.run_action(&engine, action, ctx, event_tx).await?;
}
Ok(())
}
}
impl Python {
async fn run_action(
&self,
engine: &Engine,
action: &PythonAction,
ctx: &ExecutionContext,
event_tx: &EventSender,
) -> Result<(), DependencyError> {
let delta = env_delta(action);
let idx = index_flag(action);
// ── 1. venv ──────────────────────────────────────────────
if let Some(ref dir) = action.directory {
let venv_cmd = format!("{} -m venv '{}'", python_bin(action), dir.display());
log::info!("Python UPM creating venv: {}", venv_cmd);
let r = engine
.execute_command_with_delta(&venv_cmd, ctx, event_tx, &delta)
.await;
if let Err(e) = r {
log::error!("Failed to create venv: {}", e);
return Err(DependencyError::CustomFailed(e));
}
}
// ── 2. backend command ──────────────────────────────────
match action.type_.as_str() {
"pip" => {
if !action.packages.is_empty() {
let cmd = format!(
"{} install{}{} {}",
venv_pip(action),
idx,
action.args.iter().fold(String::new(), |a, b| a + " " + b),
action.packages.join(" "),
);
log::info!("Python UPM executing: {}", cmd);
engine
.execute_command_with_delta(&cmd, ctx, event_tx, &delta)
.await
.map_err(DependencyError::CustomFailed)?;
}
if let Some(ref manifest) = action.manifest {
let cmd = format!(
"{} install{} -r '{}'{}",
venv_pip(action),
idx,
manifest.display(),
action.args.iter().fold(String::new(), |a, b| a + " " + b),
);
log::info!("Python UPM executing: {}", cmd);
engine
.execute_command_with_delta(&cmd, ctx, event_tx, &delta)
.await
.map_err(DependencyError::CustomFailed)?;
}
}
"uv" => {
// uv pip install <packages> / uv pip sync <manifest>
if !action.packages.is_empty() {
let cmd = format!(
"uv pip install{}{} {}",
idx,
action.args.iter().fold(String::new(), |a, b| a + " " + b),
action.packages.join(" "),
);
log::info!("Python UPM executing: {}", cmd);
engine
.execute_command_with_delta(&cmd, ctx, event_tx, &delta)
.await
.map_err(DependencyError::CustomFailed)?;
}
if let Some(ref manifest) = action.manifest {
let cmd = format!(
"uv pip sync '{}'{}",
manifest.display(),
action.args.iter().fold(String::new(), |a, b| a + " " + b),
);
log::info!("Python UPM executing: {}", cmd);
engine
.execute_command_with_delta(&cmd, ctx, event_tx, &delta)
.await
.map_err(DependencyError::CustomFailed)?;
}
}
"poetry" => {
let cmd = format!(
"poetry install{}",
action.args.iter().fold(String::new(), |a, b| a + " " + b),
);
log::info!("Python UPM executing: {}", cmd);
engine
.execute_command_with_delta(&cmd, ctx, event_tx, &delta)
.await
.map_err(DependencyError::CustomFailed)?;
}
"pipx" => {
if !action.packages.is_empty() {
let cmd = format!(
"pipx install{}{} {}",
idx,
action.args.iter().fold(String::new(), |a, b| a + " " + b),
action.packages.join(" "),
);
log::info!("Python UPM executing: {}", cmd);
engine
.execute_command_with_delta(&cmd, ctx, event_tx, &delta)
.await
.map_err(DependencyError::CustomFailed)?;
}
}
other => {
log::error!("Unknown Python backend type: {}", other);
return Err(DependencyError::ParseError());
}
}
Ok(())
}
}
// ── Tests ──────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_name() {
assert_eq!(Python.name(), "python");
}
#[test]
fn test_system_dependency_basic() {
let yaml = "- pip: {}";
let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let deps = Python.system_dependency(&v);
assert_eq!(deps.packages, vec!["python3"]);
}
#[test]
fn test_system_dependency_uv() {
let yaml = r#"
- type: uv
packages: [pytest]
"#;
let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let deps = Python.system_dependency(&v);
assert!(deps.packages.contains(&"python3".to_string()));
assert!(deps.packages.contains(&"uv".to_string()));
}
#[test]
fn test_parse_pip_action() {
let yaml = r#"
- type: pip
packages: [pytest, requests]
"#;
let cfg: PythonConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.0.len(), 1);
let a = &cfg.0[0];
assert_eq!(a.type_, "pip");
assert_eq!(a.packages, vec!["pytest", "requests"]);
}
#[test]
fn test_parse_full_action() {
let yaml = r#"
- type: uv
python: "python3.14"
directory: ".venv"
index: "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
packages: [pytest, requests]
manifest: "requirements.txt"
args: ["--upgrade"]
env:
UV_PYTHON: "python3.14"
"#;
let cfg: PythonConfig = serde_yaml::from_str(yaml).unwrap();
let a = &cfg.0[0];
assert_eq!(a.type_, "uv");
assert_eq!(a.python.as_deref(), Some("python3.14"));
assert_eq!(a.directory, Some(PathBuf::from(".venv")));
assert_eq!(
a.index.as_deref(),
Some("https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"),
);
assert_eq!(a.packages, vec!["pytest", "requests"]);
assert_eq!(a.manifest, Some(PathBuf::from("requirements.txt")));
assert_eq!(a.args, vec!["--upgrade"]);
assert_eq!(
a.env_.get("UV_PYTHON").and_then(|v| v.as_deref()),
Some("python3.14"),
);
}
#[test]
fn test_parse_multiple_actions() {
let yaml = r#"
- type: pip
packages: [pytest]
- type: uv
directory: ".venv"
manifest: "requirements.txt"
"#;
let cfg: PythonConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.0.len(), 2);
assert_eq!(cfg.0[0].type_, "pip");
assert_eq!(cfg.0[1].type_, "uv");
}
}
+319
View File
@@ -0,0 +1,319 @@
use crate::error::DependencyError;
use crate::types::{DeltaExecutionContext, Engine, EventSender, ExecutionContext};
use crate::upm::UPMSysDeps;
use async_trait::async_trait;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
use super::UserPackageManager;
/// Default manifest paths used when `manifests` is not specified
/// in the `[rust.cargo]` sub-config.
///
/// The bootstrap stage creates `/workspace` as the standard project
/// root. Most Docker-based builds have a single `Cargo.toml` here.
fn default_manifests() -> Vec<PathBuf> {
vec![PathBuf::from("/workspace/Cargo.toml")]
}
#[derive(Clone)]
pub struct Rust;
// ── RustConfig (root) ─────────────────────────────────────────────
/// Top-level configuration for the `rust` UPM.
///
/// ```yaml
/// rust:
/// toolchain: "nightly-2024-01-01"
/// components: ["clippy", "rustfmt"]
/// targets: ["wasm32-unknown-unknown"]
/// cargo:
/// fetch: true
/// locked: true
/// manifests: ["/workspace/Cargo.toml"]
/// packages: ["cargo-audit"]
/// ```
#[derive(Debug, Clone, Deserialize)]
struct RustConfig {
#[allow(dead_code)]
/// UPM backend type (currently only `"rustup"`).
#[serde(default = "default_rust_type", rename = "type")]
type_: String,
/// Environment variables injected into every rustup/cargo child process.
/// Each entry maps to a var name; `None` unsets the variable.
#[serde(default, rename = "env")]
env_: HashMap<String, Option<String>>,
/// Rust toolchain to activate via `rustup default`.
/// If unset, the existing default toolchain is left alone.
#[serde(default)]
toolchain: Option<String>,
/// Rustup components to install (e.g. `clippy`, `rustfmt`).
#[serde(default)]
components: Vec<String>,
/// Compilation targets for cross-compilation.
/// Each entry runs `rustup target add <target>`.
#[serde(default)]
targets: Vec<String>,
/// Cargo sub-configuration (fetch, install, manifests, …).
/// Optional — omit if you only need rustup-level setup.
#[serde(default)]
cargo: CargoSubConfig,
}
fn default_rust_type() -> String {
"rustup".to_string()
}
// ── Cargo sub-config ───────────────────────────────────────────────
/// Nested cargo configuration inside `rust`.
#[derive(Debug, Clone, Deserialize, Default)]
struct CargoSubConfig {
/// Crate names to install via `cargo install`.
#[serde(default)]
packages: Vec<String>,
/// Alternative registry URL (passed as `--registry`).
#[serde(default)]
registry: Option<String>,
/// Additional flags forwarded to `cargo install`
/// (e.g. `--force`, `--version 0.1.0`).
#[serde(default)]
args: Vec<String>,
/// One or more `Cargo.toml` paths for `cargo fetch`.
/// Each path spawns a separate `cargo fetch --manifest-path <path>`.
#[serde(default = "default_manifests")]
manifests: Vec<PathBuf>,
/// When `true`, run `cargo fetch` before installing packages.
#[serde(default)]
fetch: bool,
/// Require an up-to-date `Cargo.lock` (passes `--locked`).
#[serde(default)]
locked: bool,
}
// ── UserPackageManager impl ────────────────────────────────────────
#[async_trait]
impl UserPackageManager for Rust {
fn name(&self) -> &'static str {
"rust"
}
fn system_dependency(&self, _config: &serde_yaml::Value) -> UPMSysDeps {
UPMSysDeps::new(vec!["rustup".to_string()], false)
}
async fn main(
&self,
config: &serde_yaml::Value,
ctx: &ExecutionContext,
event_tx: &EventSender,
) -> Result<(), DependencyError> {
let cfg: RustConfig = serde_yaml::from_value(config.clone()).map_err(|e| {
log::error!("Failed to parse user.rust config: {}", e);
DependencyError::ParseError()
})?;
let engine = Engine::new();
let mut commands: Vec<String> = Vec::new();
// ── 1. rustup default <toolchain> ─────────────────────────
if let Some(ref tc) = cfg.toolchain {
commands.push(format!("rustup default '{}'", tc));
}
// ── 2. rustup component add <comp…> ──────────────────────
if !cfg.components.is_empty() {
commands.push(format!("rustup component add {}", cfg.components.join(" ")));
}
// ── 3. rustup target add <target…> ───────────────────────
if !cfg.targets.is_empty() {
commands.push(format!("rustup target add {}", cfg.targets.join(" ")));
}
// ── 4. cargo fetch ───────────────────────────────────────
//
// `cargo fetch` without --target only fetches host-target
// dependencies. For cross-compilation we must fetch once
// per (manifest, target) pair.
let cargo = &cfg.cargo;
if cargo.fetch {
let mut base = String::from("cargo fetch");
if cargo.locked {
base.push_str(" --locked");
}
for manifest in &cargo.manifests {
// Host target (no --target)
commands.push(format!("{} --manifest-path '{}'", base, manifest.display()));
// Cross-compilation targets
for target in &cfg.targets {
commands.push(format!(
"{} --target '{}' --manifest-path '{}'",
base,
target,
manifest.display()
));
}
}
}
// ── 5. cargo install ─────────────────────────────────────
if !cargo.packages.is_empty() {
let mut cmd = String::from("cargo install");
if cargo.locked {
cmd.push_str(" --locked");
}
if let Some(ref registry) = cargo.registry {
cmd.push_str(&format!(" --registry '{}'", registry));
}
for a in &cargo.args {
cmd.push(' ');
cmd.push_str(a);
}
for pkg in &cargo.packages {
cmd.push(' ');
cmd.push_str(pkg);
}
commands.push(cmd);
}
// ── Execute ──────────────────────────────────────────────
for cmd in &commands {
log::info!("Rust UPM executing: {}", cmd);
let delta = DeltaExecutionContext {
env_vars: cfg.env_.clone(),
..Default::default()
};
let result = engine
.execute_command_with_delta(cmd, ctx, event_tx, &delta)
.await;
if let Err(e) = result {
log::error!("Rust command failed: {}", e);
return Err(DependencyError::CustomFailed(e));
}
}
if commands.is_empty() {
log::warn!("Rust UPM configured with nothing to do");
}
Ok(())
}
}
// ── Tests ──────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_name() {
assert_eq!(Rust.name(), "rust");
}
#[test]
fn test_system_dependency() {
let deps = Rust.system_dependency(&serde_yaml::Value::Null);
assert_eq!(deps.packages, vec!["rustup"]);
assert!(!deps.mapped);
}
#[test]
fn test_parse_empty() {
let yaml = "{}";
let cfg: RustConfig = serde_yaml::from_str(yaml).unwrap();
assert!(cfg.toolchain.is_none());
assert!(cfg.components.is_empty());
assert!(cfg.targets.is_empty());
assert!(!cfg.cargo.fetch);
}
#[test]
fn test_parse_rustup_only() {
let yaml = r#"
toolchain: "nightly-2024-01-01"
components: ["clippy", "rustfmt"]
targets: ["wasm32-unknown-unknown", "x86_64-unknown-linux-musl"]
"#;
let cfg: RustConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.toolchain.as_deref(), Some("nightly-2024-01-01"));
assert_eq!(cfg.components, vec!["clippy", "rustfmt"]);
assert_eq!(
cfg.targets,
vec!["wasm32-unknown-unknown", "x86_64-unknown-linux-musl"]
);
assert!(!cfg.cargo.fetch);
}
#[test]
fn test_parse_cargo_subconfig() {
let yaml = r#"
components: ["clippy"]
cargo:
fetch: true
locked: true
manifests:
- "/workspace/foo/Cargo.toml"
- "/workspace/bar/Cargo.toml"
packages: ["cargo-audit"]
registry: "https://mirrors.tuna.tsinghua.edu.cn/crates.io-index"
"#;
let cfg: RustConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.components, vec!["clippy"]);
let c = &cfg.cargo;
assert!(c.fetch);
assert!(c.locked);
assert_eq!(
c.manifests,
vec![
PathBuf::from("/workspace/foo/Cargo.toml"),
PathBuf::from("/workspace/bar/Cargo.toml"),
]
);
assert_eq!(c.packages, vec!["cargo-audit"]);
assert_eq!(
c.registry.as_deref(),
Some("https://mirrors.tuna.tsinghua.edu.cn/crates.io-index")
);
}
#[test]
fn test_cargo_defaults() {
let yaml = r#"
components: ["rustfmt"]
cargo: {}
"#;
let cfg: RustConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.components, vec!["rustfmt"]);
assert!(!cfg.cargo.fetch);
assert!(!cfg.cargo.locked);
assert_eq!(
cfg.cargo.manifests,
vec![PathBuf::from("/workspace/Cargo.toml")]
);
}
#[test]
fn test_default_manifests() {
assert_eq!(
default_manifests(),
vec![PathBuf::from("/workspace/Cargo.toml")]
);
}
}