Compare commits
9 Commits
1ede07df81
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7995de547 | ||
|
|
13ab202880 | ||
|
|
bb4b2f4162 | ||
|
|
d86c79b9cb | ||
|
|
a517fba88a | ||
|
|
3570777a0d | ||
|
|
34d6a765b0 | ||
|
|
8374cb6079 | ||
|
|
7e8d7ecce5 |
3
.cargo/config.toml
Normal file
3
.cargo/config.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# This alias makes `cargo xtask` work -- it runs the xtask workspace member.
|
||||
[alias]
|
||||
xtask = "run --package xtask --"
|
||||
158
Cargo.lock
generated
158
Cargo.lock
generated
@@ -76,6 +76,7 @@ dependencies = [
|
||||
"predicates",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"toml",
|
||||
@@ -261,6 +262,41 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -380,6 +416,21 @@ version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
@@ -419,6 +470,35 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||
|
||||
[[package]]
|
||||
name = "predicates"
|
||||
version = "3.1.4"
|
||||
@@ -473,6 +553,15 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
@@ -539,6 +628,27 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scc"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
|
||||
dependencies = [
|
||||
"sdd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sdd"
|
||||
version = "3.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
@@ -591,6 +701,44 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial_test"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f"
|
||||
dependencies = [
|
||||
"futures-executor",
|
||||
"futures-util",
|
||||
"log",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"scc",
|
||||
"serial_test_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial_test_derive"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -924,6 +1072,16 @@ version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"colored",
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
[workspace]
|
||||
members = [".", "xtask"]
|
||||
default-members = ["."]
|
||||
|
||||
[package]
|
||||
name = "anvil"
|
||||
version = "1.0.0"
|
||||
@@ -47,6 +51,7 @@ tempfile = "3.13"
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2.0"
|
||||
predicates = "3.1"
|
||||
serial_test = "3"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
136
README.md
136
README.md
@@ -259,6 +259,7 @@ your-project/
|
||||
test_tmp36.cpp Library driver tests
|
||||
CMakeLists.txt Fetches Google Test, compiles tests
|
||||
build.sh / build.bat Compile sketch
|
||||
build.sh / build.bat / build.ps1 Compile sketch (bat wraps ps1)
|
||||
upload.sh / upload.bat Compile + upload to board
|
||||
monitor.sh / monitor.bat Serial monitor
|
||||
test.sh / test.bat Run host-side tests
|
||||
@@ -376,63 +377,15 @@ Anvil is written in Rust and compiles to a single static binary. You need:
|
||||
|
||||
- **Rust toolchain** (stable, 2021 edition or later)
|
||||
- **A C linker** (`gcc` or equivalent -- Rust uses it under the hood)
|
||||
- **MinGW** (only if cross-compiling a Windows binary from Linux)
|
||||
- **zip** (only for packaging release artifacts)
|
||||
|
||||
### Linux / WSL from scratch
|
||||
|
||||
A fresh Ubuntu or WSL instance needs three commands:
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# 1. System packages (C linker + cross-compile + packaging tools)
|
||||
sudo apt update && sudo apt install build-essential mingw-w64 zip
|
||||
|
||||
# 2. Rust toolchain
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source ~/.cargo/env
|
||||
|
||||
# 3. Build
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The release binary lands at `target/release/anvil`. Copy it somewhere in
|
||||
your PATH.
|
||||
|
||||
### Windows (native)
|
||||
|
||||
Install Rust from [rustup.rs](https://rustup.rs), which includes the MSVC
|
||||
toolchain. Then:
|
||||
|
||||
```
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The binary lands at `target\release\anvil.exe`.
|
||||
|
||||
### Release builds (Linux + Windows from one machine)
|
||||
|
||||
The `build-release.sh` script at the repo root builds optimized, stripped
|
||||
binaries for both platforms and packages them into tarballs and zips. It
|
||||
reads the version from `Cargo.toml` (the single source of truth) and
|
||||
accepts an optional suffix for pre-release builds:
|
||||
|
||||
```bash
|
||||
./build-release.sh # uses version from Cargo.toml (e.g. 1.0.0)
|
||||
./build-release.sh beta1 # appends suffix (e.g. 1.0.0-beta1)
|
||||
./build-release.sh rc1 # appends suffix (e.g. 1.0.0-rc1)
|
||||
```
|
||||
|
||||
This produces a `release-artifacts/` directory with:
|
||||
|
||||
```
|
||||
anvil-1.0.0-linux-x86_64.tar.gz
|
||||
anvil-1.0.0-linux-x86_64.zip
|
||||
anvil-1.0.0-windows-x86_64.zip
|
||||
SHA256SUMS
|
||||
```
|
||||
|
||||
Upload these to a Gitea release. The script requires `build-essential`,
|
||||
`mingw-w64`, and `zip` as described above.
|
||||
The release binary lands at `target/release/anvil` (or `target\release\anvil.exe`
|
||||
on Windows). Copy it somewhere in your PATH.
|
||||
|
||||
### Running the test suite
|
||||
|
||||
@@ -440,12 +393,91 @@ Upload these to a Gitea release. The script requires `build-essential`,
|
||||
cargo test
|
||||
```
|
||||
|
||||
650 tests (137 unit + 506 integration + 7 end-to-end), zero warnings. The e2e
|
||||
The test suite covers unit, integration, and end-to-end scenarios. The e2e
|
||||
tests generate real projects and compile their C++ test suites, catching
|
||||
build-system issues like missing linker flags and include paths. They require
|
||||
cmake and a C++ compiler; if those tools are not installed, the compile tests
|
||||
skip gracefully and everything else still passes.
|
||||
|
||||
#### Full test suite on Linux / WSL
|
||||
|
||||
The e2e tests need `cmake`, `g++`, and `arduino-cli` with the AVR core:
|
||||
|
||||
```bash
|
||||
sudo apt install cmake g++
|
||||
curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh
|
||||
sudo mv bin/arduino-cli /usr/local/bin/
|
||||
arduino-cli core install arduino:avr
|
||||
```
|
||||
|
||||
#### Full test suite on Windows
|
||||
|
||||
Install [arduino-cli](https://arduino.github.io/arduino-cli/installation/)
|
||||
and add it to your PATH, then install the AVR core:
|
||||
|
||||
```
|
||||
arduino-cli core install arduino:avr
|
||||
```
|
||||
|
||||
CMake and a C++ compiler are needed for the host-side test compilation.
|
||||
Install [CMake](https://cmake.org/download/) and either MinGW-w64 or open
|
||||
a Visual Studio Developer Command Prompt (which provides `cl.exe`).
|
||||
|
||||
---
|
||||
|
||||
## Building Release Binaries
|
||||
|
||||
Anvil uses a Rust `xtask` workspace member to build release binaries for all
|
||||
platforms from a single machine. No VMs, no containers, no root required.
|
||||
|
||||
### How it works
|
||||
|
||||
The xtask build system uses [cargo-zigbuild](https://github.com/rust-cross/cargo-zigbuild)
|
||||
with the [Zig](https://ziglang.org) compiler as a universal cross-linker.
|
||||
From a single FreeBSD machine you get all three platform binaries:
|
||||
|
||||
```
|
||||
release-artifacts/
|
||||
anvil-X.Y.Z-freebsd-x86_64.tar.gz native FreeBSD build
|
||||
anvil-X.Y.Z-linux-x86_64.tar.gz cross-compiled via zig
|
||||
anvil-X.Y.Z-windows-x86_64.zip cross-compiled via zig
|
||||
SHA256SUMS
|
||||
```
|
||||
|
||||
### First-time setup
|
||||
|
||||
```bash
|
||||
cargo xtask --fix
|
||||
```
|
||||
|
||||
This installs zig (via pkg), zip, cargo-zigbuild, and the required rustup
|
||||
cross-compile targets. Only the zig and zip installation steps require sudo.
|
||||
|
||||
### Building a release
|
||||
|
||||
```bash
|
||||
cargo xtask # build all platforms, version from Cargo.toml
|
||||
cargo xtask --suffix rc1 # pre-release (e.g. 1.0.0-rc1)
|
||||
```
|
||||
|
||||
### Other commands
|
||||
|
||||
```bash
|
||||
cargo xtask --check # verify all dependencies are installed
|
||||
cargo xtask --clean # remove cross-compile artifacts
|
||||
```
|
||||
|
||||
### Platform support
|
||||
|
||||
| Host | FreeBSD binary | Linux binary | Windows binary |
|
||||
|---|---|---|---|
|
||||
| FreeBSD | native | cargo-zigbuild | cargo-zigbuild |
|
||||
| Linux | not possible | native | cargo-zigbuild |
|
||||
| Windows | not possible | not tested | native |
|
||||
|
||||
FreeBSD is the recommended build host -- it produces all three binaries in
|
||||
a single run.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
101
build-release.sh
101
build-release.sh
@@ -1,101 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Build release binaries for distribution
|
||||
# This script builds both Linux and Windows binaries (cross-compile)
|
||||
#
|
||||
# Usage:
|
||||
# ./build-release.sh # version from Cargo.toml (e.g. 1.0.0)
|
||||
# ./build-release.sh beta1 # appends suffix (e.g. 1.0.0-beta1)
|
||||
# ./build-release.sh rc1 # appends suffix (e.g. 1.0.0-rc1)
|
||||
#
|
||||
# For Windows-only builds, use build-release.ps1 on Windows
|
||||
# For Linux-only builds, comment out the Windows section below
|
||||
set -e
|
||||
|
||||
# Read version from the single source of truth: Cargo.toml
|
||||
BASE_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/' | tr -d '\r')
|
||||
if [ -z "$BASE_VERSION" ]; then
|
||||
echo "Error: Could not read version from Cargo.toml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Optional suffix (beta1, rc1, etc.)
|
||||
if [ -n "$1" ]; then
|
||||
VERSION="${BASE_VERSION}-${1}"
|
||||
else
|
||||
VERSION="$BASE_VERSION"
|
||||
fi
|
||||
|
||||
RELEASE_DIR="release-artifacts"
|
||||
|
||||
echo "Building Anvil v${VERSION} release binaries..."
|
||||
echo " Version base: ${BASE_VERSION} (from Cargo.toml)"
|
||||
if [ -n "$1" ]; then
|
||||
echo " Suffix: ${1}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Clean previous artifacts
|
||||
rm -rf "$RELEASE_DIR"
|
||||
mkdir -p "$RELEASE_DIR"
|
||||
|
||||
# Build Linux binary (optimized)
|
||||
echo "Building Linux x86_64 binary..."
|
||||
cargo build --release
|
||||
strip target/release/anvil
|
||||
|
||||
# Package Linux binary
|
||||
echo "Packaging Linux binaries..."
|
||||
cd target/release
|
||||
tar -czf "../../$RELEASE_DIR/anvil-${VERSION}-linux-x86_64.tar.gz" anvil
|
||||
zip -q "../../$RELEASE_DIR/anvil-${VERSION}-linux-x86_64.zip" anvil
|
||||
cd ../..
|
||||
|
||||
# Build Windows binary (cross-compile)
|
||||
echo ""
|
||||
echo "Building Windows x86_64 binary..."
|
||||
|
||||
# Check if Windows target is installed
|
||||
if ! rustup target list | grep -q "x86_64-pc-windows-gnu (installed)"; then
|
||||
echo "Installing Windows target..."
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
fi
|
||||
|
||||
# Check if MinGW is installed
|
||||
if ! command -v x86_64-w64-mingw32-gcc &> /dev/null; then
|
||||
echo "Warning: MinGW not found. Install with: sudo apt install mingw-w64"
|
||||
echo "Skipping Windows build."
|
||||
else
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
x86_64-w64-mingw32-strip target/x86_64-pc-windows-gnu/release/anvil.exe
|
||||
|
||||
# Package Windows binary
|
||||
echo "Packaging Windows binary..."
|
||||
cd target/x86_64-pc-windows-gnu/release
|
||||
zip -q "../../../$RELEASE_DIR/anvil-${VERSION}-windows-x86_64.zip" anvil.exe
|
||||
cd ../../..
|
||||
fi
|
||||
|
||||
# Generate checksums
|
||||
echo ""
|
||||
echo "Generating checksums..."
|
||||
cd "$RELEASE_DIR"
|
||||
sha256sum * > SHA256SUMS
|
||||
cd ..
|
||||
|
||||
# Display results
|
||||
echo ""
|
||||
echo "==========================================================="
|
||||
echo " Anvil v${VERSION} release artifacts built successfully!"
|
||||
echo "==========================================================="
|
||||
echo ""
|
||||
echo "Artifacts in $RELEASE_DIR/:"
|
||||
ls -lh "$RELEASE_DIR"
|
||||
echo ""
|
||||
echo "Checksums:"
|
||||
cat "$RELEASE_DIR/SHA256SUMS"
|
||||
echo ""
|
||||
echo "Upload these files to your Gitea release:"
|
||||
echo " 1. Create release: Releases -> New Release -> Tag: v${VERSION}"
|
||||
echo " 2. Drag and drop files from $RELEASE_DIR/"
|
||||
echo " 3. Save"
|
||||
echo ""
|
||||
95
build.rs
Normal file
95
build.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
// build.rs -- Compile-time detection of optional build tools.
|
||||
//
|
||||
// Sets cfg flags that integration tests use to gracefully skip when
|
||||
// tools are missing instead of panicking with scary error messages.
|
||||
//
|
||||
// has_cmake cmake is in PATH
|
||||
// has_cpp_compiler g++, clang++, or cl is in PATH
|
||||
// has_git git is in PATH
|
||||
// has_arduino_cli arduino-cli is in PATH
|
||||
//
|
||||
// Usage in tests:
|
||||
// #[cfg_attr(not(has_cmake), ignore = "cmake not found")]
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Check if a tool is available by running it with --version.
|
||||
/// Works for most tools (cmake, git, g++, clang++, arduino-cli).
|
||||
fn has_tool(name: &str) -> bool {
|
||||
Command::new(name)
|
||||
.arg("--version")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Check if a C++ compiler is available.
|
||||
///
|
||||
/// g++ and clang++ respond to --version normally. MSVC's cl.exe does
|
||||
/// not -- it rejects --version and returns an error. On Windows we
|
||||
/// fall back to checking PATH via `where cl`. On Unix, cmake cannot
|
||||
/// discover MSVC so we only check g++ and clang++.
|
||||
fn has_cpp_compiler() -> bool {
|
||||
if has_tool("g++") || has_tool("clang++") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// cl.exe doesn't support --version; check PATH directly on Windows.
|
||||
// On a regular command prompt cl may not be in PATH, but cmake can
|
||||
// still find MSVC via the Visual Studio registry. We check `where`
|
||||
// as a best-effort signal.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let found = Command::new("where")
|
||||
.arg("cl")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if found {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Last resort: cmake can discover MSVC even when cl is not in
|
||||
// PATH. Check if any Visual Studio installation exists by
|
||||
// looking for vswhere, which ships with VS 2017+.
|
||||
let vswhere = Command::new("cmd")
|
||||
.args(["/C", "where", "vswhere"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if vswhere {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
let _ = (); // silence unused warning
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Declare custom cfg names so rustc doesn't warn about them.
|
||||
println!("cargo::rustc-check-cfg=cfg(has_cmake)");
|
||||
println!("cargo::rustc-check-cfg=cfg(has_cpp_compiler)");
|
||||
println!("cargo::rustc-check-cfg=cfg(has_git)");
|
||||
println!("cargo::rustc-check-cfg=cfg(has_arduino_cli)");
|
||||
|
||||
if has_tool("cmake") {
|
||||
println!("cargo:rustc-cfg=has_cmake");
|
||||
}
|
||||
if has_cpp_compiler() {
|
||||
println!("cargo:rustc-cfg=has_cpp_compiler");
|
||||
}
|
||||
if has_tool("git") {
|
||||
println!("cargo:rustc-cfg=has_git");
|
||||
}
|
||||
if has_tool("arduino-cli") {
|
||||
println!("cargo:rustc-cfg=has_arduino_cli");
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,10 @@ impl PortInfo {
|
||||
if self.port_name.contains("ttyUSB") || self.port_name.contains("ttyACM") {
|
||||
return true;
|
||||
}
|
||||
// FreeBSD: cuaU* are USB serial ports
|
||||
if self.port_name.contains("cuaU") {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
@@ -87,11 +91,18 @@ struct MatchingBoard {
|
||||
}
|
||||
|
||||
/// Enumerate serial ports via `arduino-cli board list --format json`.
|
||||
/// Falls back to OS-level detection if arduino-cli is unavailable.
|
||||
/// Falls back to OS-level detection if arduino-cli is unavailable or returns
|
||||
/// empty results (e.g. on FreeBSD where serial-discovery uses unsupported syscalls).
|
||||
pub fn list_ports() -> Vec<PortInfo> {
|
||||
if let Some(cli) = find_arduino_cli() {
|
||||
if let Ok(ports) = list_ports_via_cli(&cli) {
|
||||
return ports;
|
||||
if !ports.is_empty() {
|
||||
return ports;
|
||||
}
|
||||
// arduino-cli succeeded but found nothing -- on FreeBSD its
|
||||
// serial-discovery subprocess uses Linux syscalls that are not
|
||||
// implemented, so it always returns empty. Fall through to the
|
||||
// OS-level scan which checks /dev/cuaU* directly.
|
||||
}
|
||||
}
|
||||
list_ports_fallback()
|
||||
@@ -171,12 +182,22 @@ fn list_ports_fallback() -> Vec<PortInfo> {
|
||||
if let Ok(entries) = fs::read_dir("/dev") {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("ttyUSB") || name.starts_with("ttyACM") {
|
||||
// Linux: ttyUSB* (CH340/FTDI), ttyACM* (Arduino native USB)
|
||||
// FreeBSD: cuaU* (USB serial)
|
||||
// On FreeBSD, cuaU0.init and cuaU0.lock are control nodes,
|
||||
// not actual ports -- only match bare cuaU* names.
|
||||
let is_usb_port = name.starts_with("ttyUSB")
|
||||
|| name.starts_with("ttyACM")
|
||||
|| (name.starts_with("cuaU")
|
||||
&& !name.contains('.'));
|
||||
if is_usb_port {
|
||||
let path = format!("/dev/{}", name);
|
||||
let board = if name.starts_with("ttyUSB") {
|
||||
"Likely CH340/FTDI (run 'anvil setup' for full detection)"
|
||||
} else {
|
||||
} else if name.starts_with("ttyACM") {
|
||||
"Likely Arduino (run 'anvil setup' for full detection)"
|
||||
} else {
|
||||
"USB serial device (run 'anvil setup' for full detection)"
|
||||
};
|
||||
result.push(PortInfo {
|
||||
port_name: path,
|
||||
@@ -306,14 +327,23 @@ pub fn print_port_details(ports: &[PortInfo]) {
|
||||
println!(" {}", "No serial devices found.".yellow());
|
||||
println!();
|
||||
println!(" Checklist:");
|
||||
println!(" 1. Is the board plugged in via USB?");
|
||||
println!(" 2. Is the USB cable a data cable (not charge-only)?");
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
println!(" 3. Check kernel log: dmesg | tail -20");
|
||||
println!(" 4. Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'");
|
||||
let mut n = 1u32;
|
||||
println!(" {}. Is the board plugged in via USB?", n); n += 1;
|
||||
println!(" {}. Is the USB cable a data cable (not charge-only)?", n); n += 1;
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
println!(" {}. Check kernel log: dmesg | tail -20", n); n += 1;
|
||||
println!(" {}. Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'", n); n += 1;
|
||||
}
|
||||
#[cfg(target_os = "freebsd")]
|
||||
{
|
||||
println!(" {}. Check kernel log: dmesg | grep -i usb", n); n += 1;
|
||||
println!(" {}. List USB devices: usbconfig list", n); n += 1;
|
||||
println!(" {}. Board should appear as /dev/cuaU0 (or cuaU1, cuaU2...)", n); n += 1;
|
||||
}
|
||||
println!(" {}. Try a different USB port or cable", n);
|
||||
}
|
||||
println!(" 5. Try a different USB port or cable");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,20 @@ pub fn scan_devices() -> Result<()> {
|
||||
let ports = board::list_ports();
|
||||
board::print_port_details(&ports);
|
||||
|
||||
// Also run arduino-cli board list for cross-reference
|
||||
#[cfg(target_os = "freebsd")]
|
||||
{
|
||||
println!();
|
||||
println!(
|
||||
" {}",
|
||||
"Tip: if your board is not showing up, try plugging it directly into the machine -- some USB hubs prevent boards from being detected on FreeBSD.".bright_black()
|
||||
);
|
||||
}
|
||||
|
||||
// Also run arduino-cli board list for cross-reference.
|
||||
// On FreeBSD, arduino-cli is a Linux binary whose serial-discovery subprocess
|
||||
// uses syscalls not implemented in the Linux compat layer -- skip it entirely
|
||||
// since we already detected ports via the OS fallback above.
|
||||
#[cfg(not(target_os = "freebsd"))]
|
||||
if let Some(cli_path) = board::find_arduino_cli() {
|
||||
println!();
|
||||
println!(
|
||||
@@ -62,6 +75,14 @@ pub fn scan_devices() -> Result<()> {
|
||||
println!(" - Check kernel log: dmesg | tail -20");
|
||||
println!(" - Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'");
|
||||
}
|
||||
#[cfg(target_os = "freebsd")]
|
||||
{
|
||||
println!(" - Check kernel log: dmesg | grep -i usb");
|
||||
println!(" - List USB devices: usbconfig list");
|
||||
println!(" - Board should appear as /dev/cuaU0 (or cuaU1, cuaU2...)");
|
||||
println!(" - Make sure you are in the dialer group: sudo pw groupmod dialer -m $USER");
|
||||
println!(" - Then log out and back in for group change to take effect");
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
println!(" - Open Device Manager and check Ports (COM & LPT)");
|
||||
|
||||
@@ -10,7 +10,9 @@ pub struct SystemHealth {
|
||||
pub arduino_cli_path: Option<String>,
|
||||
pub avr_core_ok: bool,
|
||||
pub avr_size_ok: bool,
|
||||
pub avr_size_path: Option<String>,
|
||||
pub dialout_ok: bool,
|
||||
pub operator_ok: bool,
|
||||
pub cmake_ok: bool,
|
||||
pub cpp_compiler_ok: bool,
|
||||
pub cpp_on_path: bool,
|
||||
@@ -43,10 +45,10 @@ pub fn run_diagnostics(fix: bool) -> Result<()> {
|
||||
.bold()
|
||||
);
|
||||
if fix {
|
||||
// Check optional tools
|
||||
let optional_missing = !health.cmake_ok
|
||||
|| !health.cpp_compiler_ok
|
||||
|| !health.git_ok;
|
||||
|| !health.git_ok
|
||||
|| !health.avr_size_ok;
|
||||
if optional_missing {
|
||||
println!();
|
||||
run_fix_optional(&health)?;
|
||||
@@ -76,13 +78,11 @@ pub fn run_diagnostics(fix: bool) -> Result<()> {
|
||||
}
|
||||
|
||||
pub fn check_system_health() -> SystemHealth {
|
||||
// arduino-cli
|
||||
let (arduino_cli_ok, arduino_cli_path) = match board::find_arduino_cli() {
|
||||
Some(path) => (true, Some(path.display().to_string())),
|
||||
None => (false, None),
|
||||
};
|
||||
|
||||
// AVR core
|
||||
let avr_core_ok = if let Some(ref path_str) = arduino_cli_path {
|
||||
let path = std::path::Path::new(path_str);
|
||||
board::is_avr_core_installed(path)
|
||||
@@ -90,16 +90,11 @@ pub fn check_system_health() -> SystemHealth {
|
||||
false
|
||||
};
|
||||
|
||||
// avr-size (optional)
|
||||
let avr_size_ok = which::which("avr-size").is_ok();
|
||||
|
||||
// dialout group (Linux only)
|
||||
let (avr_size_ok, avr_size_path) = find_avr_size();
|
||||
let dialout_ok = check_dialout();
|
||||
|
||||
// cmake (optional -- for host tests)
|
||||
let operator_ok = check_operator_group();
|
||||
let cmake_ok = which::which("cmake").is_ok();
|
||||
|
||||
// C++ compiler (optional -- for host tests)
|
||||
let cpp_on_path = which::which("g++").is_ok()
|
||||
|| which::which("clang++").is_ok()
|
||||
|| which::which("cl").is_ok();
|
||||
@@ -109,10 +104,7 @@ pub fn check_system_health() -> SystemHealth {
|
||||
has_cpp_compiler()
|
||||
};
|
||||
|
||||
// git
|
||||
let git_ok = which::which("git").is_ok();
|
||||
|
||||
// Serial ports
|
||||
let ports_found = board::list_ports().len();
|
||||
|
||||
SystemHealth {
|
||||
@@ -120,7 +112,9 @@ pub fn check_system_health() -> SystemHealth {
|
||||
arduino_cli_path,
|
||||
avr_core_ok,
|
||||
avr_size_ok,
|
||||
avr_size_path,
|
||||
dialout_ok,
|
||||
operator_ok,
|
||||
cmake_ok,
|
||||
cpp_compiler_ok,
|
||||
cpp_on_path,
|
||||
@@ -129,7 +123,6 @@ pub fn check_system_health() -> SystemHealth {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for a C++ compiler on any platform.
|
||||
fn has_cpp_compiler() -> bool {
|
||||
if which::which("g++").is_ok() || which::which("clang++").is_ok() {
|
||||
return true;
|
||||
@@ -139,8 +132,6 @@ fn has_cpp_compiler() -> bool {
|
||||
if which::which("cl").is_ok() {
|
||||
return true;
|
||||
}
|
||||
// cl.exe may be installed via VS Build Tools but not on PATH.
|
||||
// Check via vswhere.exe (ships with VS installer).
|
||||
if let Ok(output) = std::process::Command::new(
|
||||
r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe",
|
||||
)
|
||||
@@ -161,7 +152,6 @@ fn print_diagnostics(health: &SystemHealth) {
|
||||
println!("{}", "Required:".bright_yellow().bold());
|
||||
println!();
|
||||
|
||||
// arduino-cli
|
||||
if health.arduino_cli_ok {
|
||||
println!(
|
||||
" {} arduino-cli {}",
|
||||
@@ -176,7 +166,6 @@ fn print_diagnostics(health: &SystemHealth) {
|
||||
println!(" {} arduino-cli {}", "MISSING".red(), "not found in PATH".red());
|
||||
}
|
||||
|
||||
// AVR core
|
||||
if health.avr_core_ok {
|
||||
println!(" {} arduino:avr core installed", "ok".green());
|
||||
} else if health.arduino_cli_ok {
|
||||
@@ -197,7 +186,6 @@ fn print_diagnostics(health: &SystemHealth) {
|
||||
println!("{}", "Optional:".bright_yellow().bold());
|
||||
println!();
|
||||
|
||||
// avr-size
|
||||
if health.avr_size_ok {
|
||||
println!(" {} avr-size (binary size reporting)", "ok".green());
|
||||
} else if !health.avr_core_ok {
|
||||
@@ -207,39 +195,53 @@ fn print_diagnostics(health: &SystemHealth) {
|
||||
"included with arduino:avr core (no separate install)".yellow()
|
||||
);
|
||||
} else {
|
||||
let hint = hint_avr_size_not_on_path(&health.avr_size_path);
|
||||
println!(
|
||||
" {} avr-size {}",
|
||||
"na".yellow(),
|
||||
hint_avr_size_not_on_path().yellow()
|
||||
hint.as_str().yellow()
|
||||
);
|
||||
}
|
||||
|
||||
// dialout
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let group_name = serial_group_name();
|
||||
if health.dialout_ok {
|
||||
println!(" {} user in dialout group", "ok".green());
|
||||
println!(" {} user in {} group", "ok".green(), group_name);
|
||||
} else {
|
||||
let fix_cmd = serial_group_fix_command();
|
||||
println!(
|
||||
" {} dialout group {}",
|
||||
" {} {} group {}",
|
||||
"WARN".yellow(),
|
||||
"run: sudo usermod -aG dialout $USER".yellow()
|
||||
group_name,
|
||||
format!("run: {} (then log out and back in)", fix_cmd).yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "freebsd")]
|
||||
{
|
||||
if health.operator_ok {
|
||||
println!(" {} user in operator group (USB device access)", "ok".green());
|
||||
} else {
|
||||
println!(
|
||||
" {} operator group {}",
|
||||
"WARN".yellow(),
|
||||
"run: sudo pw groupmod operator -m $USER (then log out and back in)".yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// cmake
|
||||
if health.cmake_ok {
|
||||
println!(" {} cmake (for host-side tests)", "ok".green());
|
||||
} else {
|
||||
println!(
|
||||
" {} cmake {}",
|
||||
"na".yellow(),
|
||||
hint_cmake().yellow()
|
||||
hint_cmake().as_str().yellow()
|
||||
);
|
||||
}
|
||||
|
||||
// C++ compiler
|
||||
if health.cpp_on_path {
|
||||
println!(" {} C++ compiler", "ok".green());
|
||||
} else if health.cpp_compiler_ok {
|
||||
@@ -252,18 +254,17 @@ fn print_diagnostics(health: &SystemHealth) {
|
||||
println!(
|
||||
" {} C++ compiler {}",
|
||||
"na".yellow(),
|
||||
hint_cpp_compiler().yellow()
|
||||
hint_cpp_compiler().as_str().yellow()
|
||||
);
|
||||
}
|
||||
|
||||
// git
|
||||
if health.git_ok {
|
||||
println!(" {} git", "ok".green());
|
||||
} else {
|
||||
println!(
|
||||
" {} git {}",
|
||||
"na".yellow(),
|
||||
hint_git().yellow()
|
||||
hint_git().as_str().yellow()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -273,20 +274,41 @@ fn print_diagnostics(health: &SystemHealth) {
|
||||
|
||||
if health.ports_found > 0 {
|
||||
println!(
|
||||
" {} {} serial port(s) detected",
|
||||
" {} {} Arduino board(s) detected",
|
||||
"ok".green(),
|
||||
health.ports_found
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} no serial ports {}",
|
||||
"na".yellow(),
|
||||
"(plug in a board to detect)".yellow()
|
||||
);
|
||||
// On FreeBSD, missing group membership prevents port detection entirely --
|
||||
// make that clear so the user doesn't chase a phantom hardware problem.
|
||||
#[cfg(target_os = "freebsd")]
|
||||
{
|
||||
if !health.dialout_ok || !health.operator_ok {
|
||||
println!(
|
||||
" {} no board detected {}",
|
||||
"na".yellow(),
|
||||
"(cannot scan for boards until group access is fixed -- resolve the WARNs above and log out and back in)".yellow()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} no board detected {}",
|
||||
"na".yellow(),
|
||||
"(plug in your Arduino -- on FreeBSD boards appear as /dev/cuaU0)".yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "freebsd"))]
|
||||
{
|
||||
println!(
|
||||
" {} no board detected {}",
|
||||
"na".yellow(),
|
||||
"(plug in your Arduino to continue)".yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Print step-by-step fix instructions when required items are missing.
|
||||
fn print_fix_instructions(health: &SystemHealth) {
|
||||
println!("{}", "How to fix:".bright_cyan().bold());
|
||||
println!();
|
||||
@@ -310,11 +332,32 @@ fn print_fix_instructions(health: &SystemHealth) {
|
||||
println!(" Option C -- Direct download:");
|
||||
println!(
|
||||
" {}",
|
||||
"https://arduino.github.io/arduino-cli/installation/"
|
||||
.bright_cyan()
|
||||
"https://arduino.github.io/arduino-cli/installation/".bright_cyan()
|
||||
);
|
||||
} else if cfg!(target_os = "macos") {
|
||||
println!(" {}", "brew install arduino-cli".bright_cyan());
|
||||
} else if is_freebsd() {
|
||||
println!();
|
||||
println!(" FreeBSD -- download the Linux binary (runs via Linux compat layer):");
|
||||
println!(
|
||||
" {}",
|
||||
"curl -L https://downloads.arduino.cc/arduino-cli/arduino-cli_latest_Linux_64bit.tar.gz -o /tmp/arduino-cli.tar.gz"
|
||||
.bright_cyan()
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
"tar xzf /tmp/arduino-cli.tar.gz -C /tmp".bright_cyan()
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
"sudo mv /tmp/arduino-cli /usr/local/bin/".bright_cyan()
|
||||
);
|
||||
println!();
|
||||
println!(" Ensure Linux compat layer is active:");
|
||||
println!(
|
||||
" {}",
|
||||
"sudo sysrc linux_enable=YES && sudo service linux start".bright_cyan()
|
||||
);
|
||||
} else {
|
||||
println!();
|
||||
println!(" Option A -- Install script:");
|
||||
@@ -362,46 +405,54 @@ fn print_fix_instructions(health: &SystemHealth) {
|
||||
println!(
|
||||
" {}. {}",
|
||||
step,
|
||||
"Install the AVR core and verify everything:"
|
||||
.bright_white()
|
||||
.bold()
|
||||
"Install the AVR core and verify everything:".bright_white().bold()
|
||||
);
|
||||
println!(" {}", "anvil setup".bright_cyan());
|
||||
println!();
|
||||
// step += 1;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
if !health.dialout_ok {
|
||||
let fix_cmd = serial_group_fix_command();
|
||||
println!(
|
||||
" {}",
|
||||
format!(
|
||||
"Tip: add yourself to the serial group for board upload access: {}",
|
||||
fix_cmd
|
||||
)
|
||||
.bright_black()
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "freebsd")]
|
||||
if !health.operator_ok {
|
||||
println!(
|
||||
" {}",
|
||||
"Tip: add yourself to the operator group for USB device access: sudo pw groupmod operator -m $USER"
|
||||
.bright_black()
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
if !health.git_ok {
|
||||
println!(
|
||||
" {}",
|
||||
"Tip: git is optional but recommended for version control."
|
||||
.bright_black()
|
||||
"Tip: git is optional but recommended for version control.".bright_black()
|
||||
);
|
||||
if cfg!(target_os = "windows") {
|
||||
println!(
|
||||
" {}",
|
||||
"winget install Git.Git".bright_black()
|
||||
);
|
||||
println!(" {}", "winget install Git.Git".bright_black());
|
||||
} else if cfg!(target_os = "macos") {
|
||||
println!(
|
||||
" {}",
|
||||
"xcode-select --install".bright_black()
|
||||
);
|
||||
println!(" {}", "xcode-select --install".bright_black());
|
||||
} else if is_freebsd() {
|
||||
println!(" {}", "sudo pkg install git".bright_black());
|
||||
} else {
|
||||
println!(
|
||||
" {}",
|
||||
"sudo apt install git".bright_black()
|
||||
);
|
||||
println!(" {}", "sudo apt install git".bright_black());
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// --fix: automated installation
|
||||
// ==========================================================================
|
||||
|
||||
/// Prompt the user for yes/no confirmation.
|
||||
fn confirm(prompt: &str) -> bool {
|
||||
print!("{} [Y/n] ", prompt);
|
||||
io::stdout().flush().ok();
|
||||
@@ -413,7 +464,6 @@ fn confirm(prompt: &str) -> bool {
|
||||
trimmed.is_empty() || trimmed == "y" || trimmed == "yes"
|
||||
}
|
||||
|
||||
/// Run a command, streaming output to the terminal.
|
||||
fn run_cmd(program: &str, args: &[&str]) -> bool {
|
||||
println!(
|
||||
" {} {} {}",
|
||||
@@ -428,7 +478,6 @@ fn run_cmd(program: &str, args: &[&str]) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Detect which package manager is available on the system.
|
||||
fn detect_package_manager() -> Option<&'static str> {
|
||||
if cfg!(target_os = "windows") {
|
||||
if which::which("winget").is_ok() {
|
||||
@@ -444,8 +493,13 @@ fn detect_package_manager() -> Option<&'static str> {
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else if is_freebsd() {
|
||||
if which::which("pkg").is_ok() {
|
||||
Some("pkg")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
// Linux
|
||||
if which::which("apt").is_ok() {
|
||||
Some("apt")
|
||||
} else if which::which("dnf").is_ok() {
|
||||
@@ -458,11 +512,9 @@ fn detect_package_manager() -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fix required items (arduino-cli, avr core).
|
||||
fn run_fix(health: &SystemHealth) -> Result<()> {
|
||||
let pm = detect_package_manager();
|
||||
|
||||
// -- arduino-cli --
|
||||
if !health.arduino_cli_ok {
|
||||
println!(
|
||||
"{}",
|
||||
@@ -524,10 +576,39 @@ fn run_fix(health: &SystemHealth) -> Result<()> {
|
||||
println!("{} arduino-cli installed.", "ok".green());
|
||||
}
|
||||
}
|
||||
Some("dnf") => {
|
||||
Some("pkg") => {
|
||||
println!(
|
||||
" arduino-cli is not in dnf repos. Install manually:"
|
||||
" {}",
|
||||
"arduino-cli is not in the FreeBSD ports tree.".bright_yellow()
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
"It runs via the Linux compatibility layer (linux_enable must be YES).".bright_black()
|
||||
);
|
||||
if confirm("Download and install the Linux arduino-cli binary now?") {
|
||||
if !run_cmd("curl", &[
|
||||
"-L",
|
||||
"https://downloads.arduino.cc/arduino-cli/arduino-cli_latest_Linux_64bit.tar.gz",
|
||||
"-o", "/tmp/arduino-cli.tar.gz",
|
||||
]) {
|
||||
println!("{} Download failed. Check your internet connection.", "FAIL".red());
|
||||
return Ok(());
|
||||
}
|
||||
run_cmd("tar", &["xzf", "/tmp/arduino-cli.tar.gz", "-C", "/tmp"]);
|
||||
if !run_cmd("sudo", &["mv", "/tmp/arduino-cli", "/usr/local/bin/arduino-cli"]) {
|
||||
println!("{} Could not move binary to /usr/local/bin/.", "FAIL".red());
|
||||
return Ok(());
|
||||
}
|
||||
println!("{} arduino-cli installed.", "ok".green());
|
||||
println!(
|
||||
" {}",
|
||||
"If it fails to run, ensure Linux compat is active: sudo sysrc linux_enable=YES && sudo service linux start"
|
||||
.bright_black()
|
||||
);
|
||||
}
|
||||
}
|
||||
Some("dnf") => {
|
||||
println!(" arduino-cli is not in dnf repos. Install manually:");
|
||||
println!(
|
||||
" {}",
|
||||
"curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh"
|
||||
@@ -545,19 +626,15 @@ fn run_fix(health: &SystemHealth) -> Result<()> {
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
println!(
|
||||
" No supported package manager found. Install manually:"
|
||||
);
|
||||
println!(" No supported package manager found. Install manually:");
|
||||
println!(
|
||||
" {}",
|
||||
"https://arduino.github.io/arduino-cli/installation/"
|
||||
.bright_cyan()
|
||||
"https://arduino.github.io/arduino-cli/installation/".bright_cyan()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check after install
|
||||
if board::find_arduino_cli().is_none() {
|
||||
println!();
|
||||
println!(
|
||||
@@ -568,7 +645,6 @@ fn run_fix(health: &SystemHealth) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// -- AVR core --
|
||||
if !health.avr_core_ok {
|
||||
if health.arduino_cli_ok || board::find_arduino_cli().is_some() {
|
||||
println!();
|
||||
@@ -589,16 +665,96 @@ fn run_fix(health: &SystemHealth) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Offer optional tools too
|
||||
run_fix_optional(health)?;
|
||||
// -- serial group (dialout / dialer) --
|
||||
#[cfg(unix)]
|
||||
if !health.dialout_ok {
|
||||
let group = serial_group_name();
|
||||
let fix_cmd = serial_group_fix_command();
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
format!("You are not in the {} group (needed for board upload).", group).bright_yellow()
|
||||
);
|
||||
if confirm(&format!("Add yourself to {} now?", group)) {
|
||||
let parts: Vec<&str> = fix_cmd.split_whitespace().collect();
|
||||
if run_cmd(parts[0], &parts[1..]) {
|
||||
println!("{} Added to {} group. Log out and back in to apply.", "ok".green(), group);
|
||||
} else {
|
||||
println!("{} Failed. Run manually: {}", "FAIL".red(), fix_cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- operator group (FreeBSD USB device access) --
|
||||
#[cfg(target_os = "freebsd")]
|
||||
if !health.operator_ok {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
"You are not in the operator group (needed for USB device access on FreeBSD).".bright_yellow()
|
||||
);
|
||||
if confirm("Add yourself to operator group now?") {
|
||||
if run_cmd("sudo", &["pw", "groupmod", "operator", "-m", &whoami()]) {
|
||||
println!("{} Added to operator group. Log out and back in to apply.", "ok".green());
|
||||
} else {
|
||||
println!("{} Failed. Run manually: sudo pw groupmod operator -m $USER", "FAIL".red());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run_fix_optional(health)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fix optional items (cmake, C++ compiler, git).
|
||||
fn run_fix_optional(health: &SystemHealth) -> Result<()> {
|
||||
let pm = detect_package_manager();
|
||||
|
||||
// -- avr-size PATH fix --
|
||||
if !health.avr_size_ok {
|
||||
if let Some(ref bin_path) = health.avr_size_path {
|
||||
let bin_dir = std::path::Path::new(bin_path)
|
||||
.parent()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_default();
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
"avr-size is installed but not on PATH.".bright_yellow()
|
||||
);
|
||||
println!(
|
||||
" Found at: {}",
|
||||
bin_path.bright_cyan()
|
||||
);
|
||||
if !bin_dir.is_empty() {
|
||||
let shell_rc = detect_shell_rc();
|
||||
let export_line = format!(r#"export PATH="{}:$PATH""#, bin_dir);
|
||||
if confirm(&format!("Add {} to PATH in {}?", bin_dir, shell_rc)) {
|
||||
let rc_path = std::path::Path::new(&shell_rc);
|
||||
let existing = std::fs::read_to_string(rc_path).unwrap_or_default();
|
||||
if existing.contains(&export_line) {
|
||||
println!("{} Already in {} -- log out and back in to apply.", "ok".green(), shell_rc);
|
||||
} else {
|
||||
let line = format!("
|
||||
# Added by anvil doctor --fix
|
||||
{}
|
||||
", export_line);
|
||||
if std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(rc_path)
|
||||
.and_then(|mut f| { use std::io::Write; f.write_all(line.as_bytes()) })
|
||||
.is_ok()
|
||||
{
|
||||
println!("{} Added to {}. Log out and back in to apply.", "ok".green(), shell_rc);
|
||||
} else {
|
||||
println!("{} Could not write to {}. Add manually:", "FAIL".red(), shell_rc);
|
||||
println!(" {}", export_line.bright_cyan());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let items: Vec<(&str, bool, FixSpec)> = vec![
|
||||
("cmake", health.cmake_ok, fix_spec_cmake(pm)),
|
||||
("C++ compiler", health.cpp_compiler_ok, fix_spec_cpp(pm)),
|
||||
@@ -606,6 +762,9 @@ fn run_fix_optional(health: &SystemHealth) -> Result<()> {
|
||||
];
|
||||
|
||||
let missing: Vec<_> = items.iter().filter(|(_, ok, _)| !ok).collect();
|
||||
if missing.is_empty() && health.avr_size_ok {
|
||||
return Ok(());
|
||||
}
|
||||
if missing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -666,6 +825,11 @@ fn fix_spec_cmake(pm: Option<&str>) -> FixSpec {
|
||||
program: "sudo",
|
||||
args: &["apt", "install", "-y", "cmake"],
|
||||
},
|
||||
Some("pkg") => FixSpec::Auto {
|
||||
prompt: "Install cmake via pkg?",
|
||||
program: "sudo",
|
||||
args: &["pkg", "install", "-y", "cmake"],
|
||||
},
|
||||
Some("dnf") => FixSpec::Auto {
|
||||
prompt: "Install cmake via dnf?",
|
||||
program: "sudo",
|
||||
@@ -702,6 +866,9 @@ fn fix_spec_cpp(pm: Option<&str>) -> FixSpec {
|
||||
program: "sudo",
|
||||
args: &["apt", "install", "-y", "g++"],
|
||||
},
|
||||
Some("pkg") => FixSpec::Manual {
|
||||
message: "clang++ is already part of the FreeBSD base system",
|
||||
},
|
||||
Some("dnf") => FixSpec::Auto {
|
||||
prompt: "Install g++ via dnf?",
|
||||
program: "sudo",
|
||||
@@ -740,6 +907,11 @@ fn fix_spec_git(pm: Option<&str>) -> FixSpec {
|
||||
program: "sudo",
|
||||
args: &["apt", "install", "-y", "git"],
|
||||
},
|
||||
Some("pkg") => FixSpec::Auto {
|
||||
prompt: "Install git via pkg?",
|
||||
program: "sudo",
|
||||
args: &["pkg", "install", "-y", "git"],
|
||||
},
|
||||
Some("dnf") => FixSpec::Auto {
|
||||
prompt: "Install git via dnf?",
|
||||
program: "sudo",
|
||||
@@ -756,57 +928,123 @@ fn fix_spec_git(pm: Option<&str>) -> FixSpec {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform-aware install hints (one-liners for the diagnostics table)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn hint_avr_size_not_on_path() -> &'static str {
|
||||
fn hint_avr_size_not_on_path(found_path: &Option<String>) -> String {
|
||||
let location = match found_path {
|
||||
Some(p) => format!("found at {} -- add its directory to PATH, or log out and back in", p),
|
||||
None => "not found -- install the AVR core first: anvil setup".to_string(),
|
||||
};
|
||||
if cfg!(target_os = "windows") {
|
||||
"installed but not on PATH (binary size reports will be skipped)"
|
||||
format!("installed but not on PATH ({})", location)
|
||||
} else {
|
||||
"installed but not on PATH"
|
||||
format!("not on PATH ({})", location)
|
||||
}
|
||||
}
|
||||
|
||||
fn hint_cmake() -> &'static str {
|
||||
/// Search common locations for avr-size and return (on_path, found_path).
|
||||
fn find_avr_size() -> (bool, Option<String>) {
|
||||
if which::which("avr-size").is_ok() {
|
||||
return (true, None);
|
||||
}
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
||||
let search_roots = vec![
|
||||
format!("{}/.arduino15/packages/arduino/tools/avr-gcc", home),
|
||||
format!("{}/.local/share/arduino15/packages/arduino/tools/avr-gcc", home),
|
||||
format!("{}\\AppData\\Local\\Arduino15\\packages\\arduino\\tools\\avr-gcc", home),
|
||||
];
|
||||
for root in &search_roots {
|
||||
let root_path = std::path::Path::new(root);
|
||||
if !root_path.exists() { continue; }
|
||||
if let Ok(entries) = std::fs::read_dir(root_path) {
|
||||
for version_entry in entries.flatten() {
|
||||
let bin_name = if cfg!(target_os = "windows") { "avr-size.exe" } else { "avr-size" };
|
||||
let bin = version_entry.path().join("bin").join(bin_name);
|
||||
if bin.exists() {
|
||||
return (false, Some(bin.display().to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(false, None)
|
||||
}
|
||||
|
||||
fn hint_cmake() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
"install: winget install Kitware.CMake (or choco install cmake)"
|
||||
"install: winget install Kitware.CMake (or choco install cmake)".into()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"install: brew install cmake"
|
||||
"install: brew install cmake".into()
|
||||
} else if is_freebsd() {
|
||||
"install: sudo pkg install cmake".into()
|
||||
} else {
|
||||
"install: sudo apt install cmake"
|
||||
match detect_package_manager() {
|
||||
Some("dnf") => "install: sudo dnf install cmake".into(),
|
||||
Some("pacman") => "install: sudo pacman -S cmake".into(),
|
||||
_ => "install: sudo apt install cmake".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hint_cpp_compiler() -> &'static str {
|
||||
fn hint_cpp_compiler() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
"install: choco install mingw (or open Developer Command Prompt for MSVC)"
|
||||
"install: choco install mingw (or open Developer Command Prompt for MSVC)".into()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"install: xcode-select --install"
|
||||
"install: xcode-select --install".into()
|
||||
} else if is_freebsd() {
|
||||
"clang++ is part of the FreeBSD base system -- check your PATH".into()
|
||||
} else {
|
||||
"install: sudo apt install g++"
|
||||
match detect_package_manager() {
|
||||
Some("dnf") => "install: sudo dnf install gcc-c++".into(),
|
||||
Some("pacman") => "install: sudo pacman -S gcc".into(),
|
||||
_ => "install: sudo apt install g++".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hint_git() -> &'static str {
|
||||
fn hint_git() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
"install: winget install Git.Git (or https://git-scm.com)"
|
||||
"install: winget install Git.Git (or https://git-scm.com)".into()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"install: xcode-select --install (or brew install git)"
|
||||
"install: xcode-select --install (or brew install git)".into()
|
||||
} else if is_freebsd() {
|
||||
"install: sudo pkg install git".into()
|
||||
} else {
|
||||
"install: sudo apt install git"
|
||||
match detect_package_manager() {
|
||||
Some("dnf") => "install: sudo dnf install git".into(),
|
||||
Some("pacman") => "install: sudo pacman -S git".into(),
|
||||
_ => "install: sudo apt install git".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_freebsd() -> bool {
|
||||
cfg!(target_os = "freebsd")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn serial_group_name() -> &'static str {
|
||||
if is_freebsd() {
|
||||
"dialer"
|
||||
} else {
|
||||
"dialout"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn serial_group_fix_command() -> String {
|
||||
if is_freebsd() {
|
||||
"sudo pw groupmod dialer -m $USER".to_string()
|
||||
} else {
|
||||
"sudo usermod -aG dialout $USER".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn check_dialout() -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let output = std::process::Command::new("groups")
|
||||
.output();
|
||||
match output {
|
||||
let group = serial_group_name();
|
||||
match std::process::Command::new("groups").output() {
|
||||
Ok(out) => {
|
||||
let groups = String::from_utf8_lossy(&out.stdout);
|
||||
groups.contains("dialout")
|
||||
groups.contains(group)
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
@@ -814,6 +1052,51 @@ fn check_dialout() -> bool {
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
true // Not applicable on Windows
|
||||
true
|
||||
}
|
||||
}
|
||||
fn check_operator_group() -> bool {
|
||||
#[cfg(target_os = "freebsd")]
|
||||
{
|
||||
match std::process::Command::new("groups").output() {
|
||||
Ok(out) => {
|
||||
let groups = String::from_utf8_lossy(&out.stdout);
|
||||
groups.contains("operator")
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "freebsd"))]
|
||||
{
|
||||
true // not applicable on other platforms
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[allow(dead_code)]
|
||||
fn whoami() -> String {
|
||||
std::process::Command::new("whoami")
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_else(|_| "$USER".to_string())
|
||||
}
|
||||
|
||||
/// Detect the user's shell RC file for PATH exports.
|
||||
fn detect_shell_rc() -> String {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||
// Check SHELL env var first
|
||||
let shell = std::env::var("SHELL").unwrap_or_default();
|
||||
if shell.contains("zsh") {
|
||||
return format!("{}/.zshrc", home);
|
||||
}
|
||||
if shell.contains("fish") {
|
||||
return format!("{}/.config/fish/config.fish", home);
|
||||
}
|
||||
// Fall back to checking which RC files exist
|
||||
let zshrc = format!("{}/.zshrc", home);
|
||||
if std::path::Path::new(&zshrc).exists() {
|
||||
return zshrc;
|
||||
}
|
||||
format!("{}/.bashrc", home)
|
||||
}
|
||||
@@ -306,6 +306,13 @@ pub fn run_refresh(
|
||||
}
|
||||
}
|
||||
|
||||
for filename in &has_changes {
|
||||
println!(
|
||||
" {} {} (updated)",
|
||||
"ok".green(),
|
||||
filename.bright_white()
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"{} Updated {} file(s).",
|
||||
"ok".green(),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
:: build.bat -- Compile the sketch using arduino-cli
|
||||
:: build.bat -- Thin wrapper that invokes build.ps1
|
||||
::
|
||||
:: Reads all settings from .anvil.toml. No Anvil binary required.
|
||||
:: Students can type "build" at a command prompt or double-click this file.
|
||||
:: All logic lives in build.ps1. Requires PowerShell 5.1+ (ships with
|
||||
:: Windows 10/11).
|
||||
::
|
||||
:: Settings are read from .anvil.toml. No Anvil binary required.
|
||||
::
|
||||
:: Usage:
|
||||
:: build.bat Compile (verify only)
|
||||
@@ -11,173 +13,5 @@ setlocal enabledelayedexpansion
|
||||
:: build.bat --clean Delete build cache first
|
||||
:: build.bat --verbose Show full compiler output
|
||||
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
|
||||
set "CONFIG=%SCRIPT_DIR%\.anvil.toml"
|
||||
|
||||
if not exist "%CONFIG%" (
|
||||
echo FAIL: No .anvil.toml found in %SCRIPT_DIR%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: -- Parse .anvil.toml (flat keys) ----------------------------------------
|
||||
for /f "usebackq tokens=1,* delims==" %%a in ("%CONFIG%") do (
|
||||
set "_K=%%a"
|
||||
if not "!_K:~0,1!"=="#" if not "!_K:~0,1!"=="[" (
|
||||
set "_K=!_K: =!"
|
||||
set "_V=%%b"
|
||||
if defined _V (
|
||||
set "_V=!_V: =!"
|
||||
set "_V=!_V:"=!"
|
||||
)
|
||||
if "!_K!"=="name" set "SKETCH_NAME=!_V!"
|
||||
if "!_K!"=="default" set "DEFAULT_BOARD=!_V!"
|
||||
if "!_K!"=="warnings" set "WARNINGS=!_V!"
|
||||
)
|
||||
)
|
||||
|
||||
if "%SKETCH_NAME%"=="" (
|
||||
echo FAIL: Could not read project name from .anvil.toml
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "SKETCH_DIR=%SCRIPT_DIR%\%SKETCH_NAME%"
|
||||
set "BUILD_DIR=%SCRIPT_DIR%\.build"
|
||||
|
||||
:: -- Parse arguments ------------------------------------------------------
|
||||
set "DO_CLEAN=0"
|
||||
set "VERBOSE="
|
||||
set "BOARD_NAME="
|
||||
|
||||
:parse_args
|
||||
if "%~1"=="" goto done_args
|
||||
if "%~1"=="--board" set "BOARD_NAME=%~2" & shift & shift & goto parse_args
|
||||
if "%~1"=="--clean" set "DO_CLEAN=1" & shift & goto parse_args
|
||||
if "%~1"=="--verbose" set "VERBOSE=--verbose" & shift & goto parse_args
|
||||
if "%~1"=="--help" goto show_help
|
||||
if "%~1"=="-h" goto show_help
|
||||
echo FAIL: Unknown option: %~1
|
||||
exit /b 1
|
||||
|
||||
:show_help
|
||||
echo Usage: build.bat [--board NAME] [--clean] [--verbose]
|
||||
echo Compiles the sketch. Settings from .anvil.toml.
|
||||
echo --board NAME selects a board from [boards.NAME].
|
||||
exit /b 0
|
||||
|
||||
:done_args
|
||||
|
||||
:: -- Resolve board --------------------------------------------------------
|
||||
if "%BOARD_NAME%"=="" set "BOARD_NAME=%DEFAULT_BOARD%"
|
||||
|
||||
if "%BOARD_NAME%"=="" (
|
||||
echo FAIL: No default board set in .anvil.toml.
|
||||
echo.
|
||||
echo Add a default to the [build] section of .anvil.toml:
|
||||
echo default = "uno"
|
||||
echo.
|
||||
echo And make sure a matching [boards.uno] section exists:
|
||||
echo [boards.uno]
|
||||
echo fqbn = "arduino:avr:uno"
|
||||
echo.
|
||||
echo Or with Anvil: anvil board --default uno
|
||||
echo List boards: anvil board --listall
|
||||
echo arduino-cli board listall
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "BOARD_SECTION=[boards.%BOARD_NAME%]"
|
||||
set "IN_SECTION=0"
|
||||
set "FQBN="
|
||||
for /f "usebackq tokens=*" %%L in ("%CONFIG%") do (
|
||||
set "_LINE=%%L"
|
||||
if "!_LINE!"=="!BOARD_SECTION!" (
|
||||
set "IN_SECTION=1"
|
||||
) else if "!IN_SECTION!"=="1" (
|
||||
if "!_LINE:~0,1!"=="[" (
|
||||
set "IN_SECTION=0"
|
||||
) else if not "!_LINE:~0,1!"=="#" (
|
||||
for /f "tokens=1,* delims==" %%a in ("!_LINE!") do (
|
||||
set "_BK=%%a"
|
||||
set "_BK=!_BK: =!"
|
||||
set "_BV=%%b"
|
||||
if defined _BV (
|
||||
set "_BV=!_BV: =!"
|
||||
set "_BV=!_BV:"=!"
|
||||
)
|
||||
if "!_BK!"=="fqbn" set "FQBN=!_BV!"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if "!FQBN!"=="" (
|
||||
echo FAIL: No [boards.%BOARD_NAME%] section in .anvil.toml.
|
||||
echo.
|
||||
echo Add it to .anvil.toml:
|
||||
echo [boards.%BOARD_NAME%]
|
||||
echo fqbn = "arduino:avr:uno" ^(replace with your board^)
|
||||
echo.
|
||||
echo Or with Anvil: anvil board --add %BOARD_NAME%
|
||||
echo List boards: anvil board --listall
|
||||
echo arduino-cli board listall
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not "%BOARD_NAME%"=="%DEFAULT_BOARD%" (
|
||||
echo ok Using board: %BOARD_NAME% -- %FQBN%
|
||||
)
|
||||
|
||||
:: -- Preflight ------------------------------------------------------------
|
||||
where arduino-cli >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo FAIL: arduino-cli not found in PATH.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "%SKETCH_DIR%" (
|
||||
echo FAIL: Sketch directory not found: %SKETCH_DIR%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: -- Clean ----------------------------------------------------------------
|
||||
if "%DO_CLEAN%"=="1" (
|
||||
if exist "%BUILD_DIR%" (
|
||||
echo Cleaning build cache...
|
||||
rmdir /s /q "%BUILD_DIR%"
|
||||
)
|
||||
)
|
||||
|
||||
:: -- Build include flags --------------------------------------------------
|
||||
set "BUILD_FLAGS="
|
||||
for %%d in (lib\hal lib\app) do (
|
||||
if exist "%SCRIPT_DIR%\%%d" (
|
||||
set "BUILD_FLAGS=!BUILD_FLAGS! -I%SCRIPT_DIR%\%%d"
|
||||
)
|
||||
)
|
||||
:: Auto-discover driver libraries (added by: anvil add <driver>)
|
||||
if exist "%SCRIPT_DIR%\lib\drivers" (
|
||||
for /d %%d in ("%SCRIPT_DIR%\lib\drivers\*") do (
|
||||
set "BUILD_FLAGS=!BUILD_FLAGS! -I%%d"
|
||||
)
|
||||
)
|
||||
set "BUILD_FLAGS=!BUILD_FLAGS! -Werror"
|
||||
|
||||
:: -- Compile --------------------------------------------------------------
|
||||
echo Compiling %SKETCH_NAME%...
|
||||
echo Board: %FQBN%
|
||||
echo Sketch: %SKETCH_DIR%
|
||||
echo.
|
||||
|
||||
if not exist "%BUILD_DIR%" mkdir "%BUILD_DIR%"
|
||||
|
||||
arduino-cli compile --fqbn %FQBN% --build-path "%BUILD_DIR%" --warnings %WARNINGS% --build-property "compiler.cpp.extra_flags=%BUILD_FLAGS%" --build-property "compiler.c.extra_flags=%BUILD_FLAGS%" %VERBOSE% "%SKETCH_DIR%"
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo FAIL: Compilation failed.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ok Compile succeeded.
|
||||
echo.
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
212
templates/basic/build.ps1
Normal file
212
templates/basic/build.ps1
Normal file
@@ -0,0 +1,212 @@
|
||||
# build.ps1 -- Compile the sketch using arduino-cli
|
||||
#
|
||||
# Reads all settings from .anvil.toml. No Anvil binary required.
|
||||
# Called by build.bat (thin wrapper) or directly:
|
||||
# powershell -File build.ps1 [--board NAME] [--clean] [--verbose]
|
||||
#
|
||||
# Exit codes: 0 = success, 1 = error
|
||||
|
||||
param(
|
||||
[string]$board = "",
|
||||
[switch]$clean,
|
||||
[switch]$verbose,
|
||||
[switch]$help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# -- Helpers ---------------------------------------------------------------
|
||||
|
||||
function Fail($msg) {
|
||||
Write-Host "FAIL: $msg" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Ok($msg) {
|
||||
Write-Host "ok $msg" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# -- Locate config ---------------------------------------------------------
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$Config = Join-Path $ScriptDir ".anvil.toml"
|
||||
|
||||
if (-not (Test-Path $Config)) {
|
||||
Fail "No .anvil.toml found in $ScriptDir"
|
||||
}
|
||||
|
||||
# -- Parse .anvil.toml -----------------------------------------------------
|
||||
# Simple line-by-line parser. Tracks current section header to support
|
||||
# [project], [build], [boards.NAME], etc.
|
||||
|
||||
$tomlData = @{}
|
||||
$currentSection = ""
|
||||
|
||||
foreach ($rawLine in Get-Content $Config) {
|
||||
$line = $rawLine.Trim()
|
||||
|
||||
# Skip blank lines and comments
|
||||
if ($line -eq "" -or $line.StartsWith("#")) { continue }
|
||||
|
||||
# Section header: [project], [boards.uno], etc.
|
||||
if ($line -match '^\[(.+)\]$') {
|
||||
$currentSection = $Matches[1]
|
||||
continue
|
||||
}
|
||||
|
||||
# Key = value (only process lines with =)
|
||||
if ($line -match '^([^=]+?)\s*=\s*(.+)$') {
|
||||
$key = $Matches[1].Trim()
|
||||
$val = $Matches[2].Trim()
|
||||
|
||||
# Strip surrounding quotes
|
||||
if ($val.StartsWith('"') -and $val.EndsWith('"')) {
|
||||
$val = $val.Substring(1, $val.Length - 2)
|
||||
}
|
||||
|
||||
# Skip array values (multi-line or inline [...])
|
||||
if ($val.StartsWith("[")) { continue }
|
||||
|
||||
$fullKey = if ($currentSection) { "$currentSection.$key" } else { $key }
|
||||
$tomlData[$fullKey] = $val
|
||||
}
|
||||
}
|
||||
|
||||
# -- Extract settings ------------------------------------------------------
|
||||
|
||||
$SketchName = $tomlData["project.name"]
|
||||
$DefaultBoard = $tomlData["build.default"]
|
||||
$Warnings = $tomlData["build.warnings"]
|
||||
|
||||
if (-not $SketchName) {
|
||||
Fail "Could not read project name from .anvil.toml"
|
||||
}
|
||||
|
||||
$SketchDir = Join-Path $ScriptDir $SketchName
|
||||
$BuildDir = Join-Path $ScriptDir ".build"
|
||||
|
||||
# -- Help ------------------------------------------------------------------
|
||||
|
||||
if ($help) {
|
||||
Write-Host "Usage: build.bat [--board NAME] [--clean] [--verbose]"
|
||||
Write-Host " Compiles the sketch. Settings from .anvil.toml."
|
||||
Write-Host " --board NAME selects a board from [boards.NAME]."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# -- Resolve board ---------------------------------------------------------
|
||||
|
||||
$BoardName = if ($board) { $board } else { $DefaultBoard }
|
||||
|
||||
if (-not $BoardName) {
|
||||
Fail @"
|
||||
No default board set in .anvil.toml.
|
||||
|
||||
Add a default to the [build] section of .anvil.toml:
|
||||
default = "uno"
|
||||
|
||||
And make sure a matching [boards.uno] section exists:
|
||||
[boards.uno]
|
||||
fqbn = "arduino:avr:uno"
|
||||
|
||||
Or with Anvil: Anvil board --default uno
|
||||
List boards: Anvil board --listall
|
||||
arduino-cli board listall
|
||||
"@
|
||||
}
|
||||
|
||||
$Fqbn = $tomlData["boards.$BoardName.fqbn"]
|
||||
|
||||
if (-not $Fqbn) {
|
||||
Fail @"
|
||||
No [boards.$BoardName] section in .anvil.toml.
|
||||
|
||||
Add it to .anvil.toml:
|
||||
[boards.$BoardName]
|
||||
fqbn = "arduino:avr:uno" (replace with your board)
|
||||
|
||||
Or with Anvil: Anvil board --add $BoardName
|
||||
List boards: Anvil board --listall
|
||||
arduino-cli board listall
|
||||
"@
|
||||
}
|
||||
|
||||
if ($BoardName -ne $DefaultBoard) {
|
||||
Ok "Using board: $BoardName -- $Fqbn"
|
||||
}
|
||||
|
||||
# -- Preflight -------------------------------------------------------------
|
||||
|
||||
$arduinoCli = Get-Command "arduino-cli" -ErrorAction SilentlyContinue
|
||||
if (-not $arduinoCli) {
|
||||
Fail "arduino-cli not found in PATH."
|
||||
}
|
||||
|
||||
if (-not (Test-Path $SketchDir)) {
|
||||
Fail "Sketch directory not found: $SketchDir"
|
||||
}
|
||||
|
||||
# -- Clean -----------------------------------------------------------------
|
||||
|
||||
if ($clean -and (Test-Path $BuildDir)) {
|
||||
Write-Host "Cleaning build cache..."
|
||||
Remove-Item -Recurse -Force $BuildDir
|
||||
}
|
||||
|
||||
# -- Build include flags ---------------------------------------------------
|
||||
|
||||
$buildFlags = @()
|
||||
foreach ($sub in @("lib\hal", "lib\app")) {
|
||||
$dir = Join-Path $ScriptDir $sub
|
||||
if (Test-Path $dir) {
|
||||
$buildFlags += "-I$dir"
|
||||
}
|
||||
}
|
||||
|
||||
# Auto-discover driver libraries (added by: anvil add <driver>)
|
||||
$driversDir = Join-Path $ScriptDir "lib\drivers"
|
||||
if (Test-Path $driversDir) {
|
||||
foreach ($d in Get-ChildItem -Path $driversDir -Directory) {
|
||||
$buildFlags += "-I$($d.FullName)"
|
||||
}
|
||||
}
|
||||
|
||||
$buildFlags += "-Werror"
|
||||
$flagsStr = $buildFlags -join " "
|
||||
|
||||
# -- Compile ---------------------------------------------------------------
|
||||
|
||||
Write-Host "Compiling $SketchName..."
|
||||
Write-Host " Board: $Fqbn"
|
||||
Write-Host " Sketch: $SketchDir"
|
||||
Write-Host ""
|
||||
|
||||
if (-not (Test-Path $BuildDir)) {
|
||||
New-Item -ItemType Directory -Path $BuildDir | Out-Null
|
||||
}
|
||||
|
||||
$compileArgs = @(
|
||||
"compile"
|
||||
"--fqbn", $Fqbn
|
||||
"--build-path", $BuildDir
|
||||
"--warnings", $Warnings
|
||||
"--build-property", "compiler.cpp.extra_flags=$flagsStr"
|
||||
"--build-property", "compiler.c.extra_flags=$flagsStr"
|
||||
)
|
||||
|
||||
if ($verbose) {
|
||||
$compileArgs += "--verbose"
|
||||
}
|
||||
|
||||
$compileArgs += $SketchDir
|
||||
|
||||
& arduino-cli @compileArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host ""
|
||||
Fail "Compilation failed."
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Ok "Compile succeeded."
|
||||
Write-Host ""
|
||||
exit 0
|
||||
@@ -38,8 +38,29 @@ toml_get() {
|
||||
}
|
||||
|
||||
toml_array() {
|
||||
(grep "^$1 " "$CONFIG" 2>/dev/null || true) | head -1 \
|
||||
| sed 's/.*\[//; s/\].*//; s/"//g; s/,/ /g' | tr -s ' '
|
||||
# Handles both single-line: key = ["a", "b"]
|
||||
# and multiline: key = [
|
||||
# "a",
|
||||
# "b",
|
||||
# ]
|
||||
awk -v key="$1" '
|
||||
$0 ~ ("^" key " *= *\\[") {
|
||||
collecting = 1
|
||||
buf = $0
|
||||
if (index($0, "]") > 0) { collecting = 0 }
|
||||
}
|
||||
collecting && NR > 1 { buf = buf " " $0 }
|
||||
collecting && index($0, "]") > 0 && NR > 1 { collecting = 0 }
|
||||
!collecting && buf != "" {
|
||||
gsub(/.*\[/, "", buf)
|
||||
gsub(/\].*/, "", buf)
|
||||
gsub(/"/, "", buf)
|
||||
gsub(/,/, " ", buf)
|
||||
print buf
|
||||
buf = ""
|
||||
exit
|
||||
}
|
||||
' "$CONFIG" | tr -s " "
|
||||
}
|
||||
|
||||
toml_section_get() {
|
||||
|
||||
@@ -24,6 +24,17 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
TEST_DIR="$SCRIPT_DIR/test"
|
||||
BUILD_DIR="$TEST_DIR/build"
|
||||
|
||||
# Portable CPU count -- BSD make requires a number after -j, GNU make does not
|
||||
cpu_count() {
|
||||
if command -v nproc &>/dev/null; then
|
||||
nproc
|
||||
elif command -v sysctl &>/dev/null; then
|
||||
sysctl -n hw.ncpu
|
||||
else
|
||||
echo 4
|
||||
fi
|
||||
}
|
||||
|
||||
# Color output
|
||||
if [[ -t 1 ]]; then
|
||||
RED=$'\033[0;31m'; GRN=$'\033[0;32m'; CYN=$'\033[0;36m'
|
||||
@@ -67,6 +78,8 @@ if ! command -v cmake &>/dev/null; then
|
||||
echo " Install:" >&2
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
echo " brew install cmake" >&2
|
||||
elif [[ "$(uname)" == "FreeBSD" ]]; then
|
||||
echo " sudo pkg install cmake" >&2
|
||||
else
|
||||
echo " sudo apt install cmake (Debian/Ubuntu)" >&2
|
||||
echo " sudo dnf install cmake (Fedora)" >&2
|
||||
@@ -102,7 +115,7 @@ if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then
|
||||
fi
|
||||
|
||||
info "Building tests..."
|
||||
cmake --build "$BUILD_DIR" --parallel 2>&1 | \
|
||||
cmake --build "$BUILD_DIR" --parallel "$(cpu_count)" 2>&1 | \
|
||||
while IFS= read -r line; do echo " $line"; done
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -17,6 +17,17 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
|
||||
# Portable CPU count -- BSD make requires a number after -j, GNU make does not
|
||||
cpu_count() {
|
||||
if command -v nproc &>/dev/null; then
|
||||
nproc
|
||||
elif command -v sysctl &>/dev/null; then
|
||||
sysctl -n hw.ncpu
|
||||
else
|
||||
echo 4
|
||||
fi
|
||||
}
|
||||
|
||||
# Color output
|
||||
if [[ -t 1 ]]; then
|
||||
RED=$'\033[0;31m'; GRN=$'\033[0;32m'; CYN=$'\033[0;36m'
|
||||
@@ -40,7 +51,7 @@ for arg in "$@"; do
|
||||
esac
|
||||
done
|
||||
|
||||
command -v cmake &>/dev/null || die "cmake not found. Install: sudo apt install cmake"
|
||||
command -v cmake &>/dev/null || die "cmake not found. Install: pkg install cmake (FreeBSD), apt install cmake (Debian/Ubuntu), dnf install cmake (Fedora)"
|
||||
command -v g++ &>/dev/null || command -v clang++ &>/dev/null || die "No C++ compiler found"
|
||||
command -v git &>/dev/null || die "git not found (needed to fetch Google Test)"
|
||||
|
||||
@@ -55,7 +66,7 @@ if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then
|
||||
fi
|
||||
|
||||
info "Building tests..."
|
||||
cmake --build "$BUILD_DIR" --parallel
|
||||
cmake --build "$BUILD_DIR" --parallel "$(cpu_count)"
|
||||
|
||||
echo ""
|
||||
info "${BLD}Running tests...${RST}"
|
||||
|
||||
@@ -39,8 +39,29 @@ toml_get() {
|
||||
}
|
||||
|
||||
toml_array() {
|
||||
(grep "^$1 " "$CONFIG" 2>/dev/null || true) | head -1 \
|
||||
| sed 's/.*\[//; s/\].*//; s/"//g; s/,/ /g' | tr -s ' '
|
||||
# Handles both single-line: key = ["a", "b"]
|
||||
# and multiline: key = [
|
||||
# "a",
|
||||
# "b",
|
||||
# ]
|
||||
awk -v key="$1" '
|
||||
$0 ~ ("^" key " *= *\\[") {
|
||||
collecting = 1
|
||||
buf = $0
|
||||
if (index($0, "]") > 0) { collecting = 0 }
|
||||
}
|
||||
collecting && NR > 1 { buf = buf " " $0 }
|
||||
collecting && index($0, "]") > 0 && NR > 1 { collecting = 0 }
|
||||
!collecting && buf != "" {
|
||||
gsub(/.*\[/, "", buf)
|
||||
gsub(/\].*/, "", buf)
|
||||
gsub(/"/, "", buf)
|
||||
gsub(/,/, " ", buf)
|
||||
print buf
|
||||
buf = ""
|
||||
exit
|
||||
}
|
||||
' "$CONFIG" | tr -s " "
|
||||
}
|
||||
|
||||
toml_section_get() {
|
||||
@@ -69,8 +90,8 @@ LOCAL_CONFIG="$SCRIPT_DIR/.anvil.local"
|
||||
LOCAL_PORT=""
|
||||
LOCAL_VID_PID=""
|
||||
if [[ -f "$LOCAL_CONFIG" ]]; then
|
||||
LOCAL_PORT="$(grep '^port ' "$LOCAL_CONFIG" 2>/dev/null | head -1 | sed 's/.*= *"\{0,1\}\([^"]*\)"\{0,1\}/\1/' | tr -d ' ')"
|
||||
LOCAL_VID_PID="$(grep '^vid_pid ' "$LOCAL_CONFIG" 2>/dev/null | head -1 | sed 's/.*= *"\{0,1\}\([^"]*\)"\{0,1\}/\1/' | tr -d ' ')"
|
||||
LOCAL_PORT="$(grep '^port ' "$LOCAL_CONFIG" 2>/dev/null | head -1 | sed 's/.*= *"\{0,1\}\([^"]*\)"\{0,1\}/\1/' | tr -d ' ' || true)"
|
||||
LOCAL_VID_PID="$(grep '^vid_pid ' "$LOCAL_CONFIG" 2>/dev/null | head -1 | sed 's/.*= *"\{0,1\}\([^"]*\)"\{0,1\}/\1/' | tr -d ' ' || true)"
|
||||
fi
|
||||
SKETCH_DIR="$SCRIPT_DIR/$SKETCH_NAME"
|
||||
BUILD_DIR="$SCRIPT_DIR/.build"
|
||||
@@ -195,7 +216,7 @@ if [[ -z "$PORT" ]]; then
|
||||
PORT=$(arduino-cli board list 2>/dev/null \
|
||||
| grep -i "serial" \
|
||||
| awk '{print $1}' \
|
||||
| grep -E 'ttyUSB|ttyACM|COM' \
|
||||
| grep -E 'ttyUSB|ttyACM|cuaU|COM' \
|
||||
| head -1)
|
||||
|
||||
if [[ -z "$PORT" ]]; then
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
// test.sh / test/run_tests.sh --> cmake, g++ (or clang++), git
|
||||
// build.sh --> arduino-cli (with arduino:avr core)
|
||||
//
|
||||
// If any dependency is missing the test FAILS -- that is intentional.
|
||||
// A build machine that ships Anvil binaries MUST have these tools.
|
||||
// If a dependency is missing, the test is SKIPPED (shown as "ignored"
|
||||
// in cargo test output). Detection happens at compile time via build.rs
|
||||
// which sets cfg flags: has_cmake, has_cpp_compiler, has_git,
|
||||
// has_arduino_cli.
|
||||
//
|
||||
// On Windows the .bat variants are tested instead.
|
||||
// ==========================================================================
|
||||
@@ -19,6 +21,7 @@ use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
use serial_test::serial;
|
||||
|
||||
use anvil::templates::{TemplateContext, TemplateManager};
|
||||
use anvil::version::ANVIL_VERSION;
|
||||
@@ -32,7 +35,7 @@ fn test_context(name: &str) -> TemplateContext {
|
||||
TemplateContext {
|
||||
project_name: name.to_string(),
|
||||
anvil_version: ANVIL_VERSION.to_string(),
|
||||
board_name: "Arduino Uno (ATmega328P)".to_string(),
|
||||
board_name: "uno".to_string(), // <-- was "Arduino Uno (ATmega328P)"
|
||||
fqbn: "arduino:avr:uno".to_string(),
|
||||
baud: 115200,
|
||||
}
|
||||
@@ -121,91 +124,6 @@ fn run_script_with_args(dir: &Path, script: &str, args: &[&str]) -> (bool, Strin
|
||||
(output.status.success(), stdout, stderr)
|
||||
}
|
||||
|
||||
/// Assert that a command-line tool is available in PATH.
|
||||
/// Panics with a clear message if not found.
|
||||
fn require_tool(name: &str) {
|
||||
let check = if cfg!(windows) {
|
||||
Command::new("where").arg(name).output()
|
||||
} else {
|
||||
Command::new("which").arg(name).output()
|
||||
};
|
||||
|
||||
match check {
|
||||
Ok(output) if output.status.success() => {}
|
||||
_ => panic!(
|
||||
"\n\n\
|
||||
===================================================================\n\
|
||||
MISSING BUILD DEPENDENCY: {name}\n\
|
||||
===================================================================\n\
|
||||
\n\
|
||||
Anvil's cargo tests REQUIRE build-machine dependencies.\n\
|
||||
Install '{name}' and re-run. See 'anvil doctor' for guidance.\n\
|
||||
\n\
|
||||
===================================================================\n"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that at least one C++ compiler is present.
|
||||
///
|
||||
/// On Windows, cmake discovers MSVC through the Visual Studio installation
|
||||
/// even when cl.exe is not directly in PATH, so we check for cl.exe as
|
||||
/// well as g++ and clang++. If none are found in PATH we still let cmake
|
||||
/// try -- it will fail at configure time with a clear message.
|
||||
fn require_cpp_compiler() {
|
||||
let check_tool = |name: &str| -> bool {
|
||||
Command::new(if cfg!(windows) { "where" } else { "which" })
|
||||
.arg(name)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
let has_gpp = check_tool("g++");
|
||||
let has_clangpp = check_tool("clang++");
|
||||
let has_cl = if cfg!(windows) { check_tool("cl") } else { false };
|
||||
|
||||
// On Windows, cmake can discover MSVC even when cl.exe is not in
|
||||
// the current PATH (via vswhere / VS installation registry). So
|
||||
// we only hard-fail on Linux/macOS where the compiler really must
|
||||
// be in PATH.
|
||||
if !has_gpp && !has_clangpp && !has_cl {
|
||||
if cfg!(windows) {
|
||||
// Warn but don't panic -- cmake will try to find MSVC
|
||||
eprintln!(
|
||||
"\n\
|
||||
WARNING: No C++ compiler (g++, clang++, cl) found in PATH.\n\
|
||||
cmake may still find MSVC via Visual Studio installation.\n\
|
||||
If tests fail, open a VS Developer Command Prompt or install\n\
|
||||
Build Tools for Visual Studio.\n"
|
||||
);
|
||||
} else {
|
||||
panic!(
|
||||
"\n\n\
|
||||
===================================================================\n\
|
||||
MISSING BUILD DEPENDENCY: C++ compiler (g++ or clang++)\n\
|
||||
===================================================================\n\
|
||||
\n\
|
||||
Install g++ or clang++ and re-run.\n\
|
||||
\n\
|
||||
===================================================================\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Require cmake + C++ compiler + git (the test script prereqs).
|
||||
fn require_test_script_deps() {
|
||||
require_tool("cmake");
|
||||
require_tool("git");
|
||||
require_cpp_compiler();
|
||||
}
|
||||
|
||||
/// Require arduino-cli (the build script prereqs).
|
||||
fn require_build_script_deps() {
|
||||
require_tool("arduino-cli");
|
||||
}
|
||||
|
||||
/// Platform-appropriate script paths.
|
||||
fn root_test_script() -> &'static str {
|
||||
if cfg!(windows) { "test.bat" } else { "test.sh" }
|
||||
@@ -282,9 +200,10 @@ fn find_file_recursive(dir: &Path, prefix: &str) -> bool {
|
||||
// ==========================================================================
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(not(has_cmake), ignore = "cmake not found")]
|
||||
#[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")]
|
||||
#[cfg_attr(not(has_git), ignore = "git not found")]
|
||||
fn test_root_test_script_executes_successfully() {
|
||||
require_test_script_deps();
|
||||
|
||||
let tmp = extract_project("root_test");
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -305,9 +224,10 @@ fn test_root_test_script_executes_successfully() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(not(has_cmake), ignore = "cmake not found")]
|
||||
#[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")]
|
||||
#[cfg_attr(not(has_git), ignore = "git not found")]
|
||||
fn test_root_test_script_tests_actually_ran() {
|
||||
require_test_script_deps();
|
||||
|
||||
let tmp = extract_project("root_verify");
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -337,9 +257,10 @@ fn test_root_test_script_tests_actually_ran() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(not(has_cmake), ignore = "cmake not found")]
|
||||
#[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")]
|
||||
#[cfg_attr(not(has_git), ignore = "git not found")]
|
||||
fn test_root_test_script_idempotent() {
|
||||
require_test_script_deps();
|
||||
|
||||
let tmp = extract_project("root_idem");
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -366,9 +287,10 @@ fn test_root_test_script_idempotent() {
|
||||
// ==========================================================================
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(not(has_cmake), ignore = "cmake not found")]
|
||||
#[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")]
|
||||
#[cfg_attr(not(has_git), ignore = "git not found")]
|
||||
fn test_inner_run_tests_script_executes_successfully() {
|
||||
require_test_script_deps();
|
||||
|
||||
let tmp = extract_project("inner_test");
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -389,9 +311,10 @@ fn test_inner_run_tests_script_executes_successfully() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(not(has_cmake), ignore = "cmake not found")]
|
||||
#[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")]
|
||||
#[cfg_attr(not(has_git), ignore = "git not found")]
|
||||
fn test_inner_run_tests_google_tests_actually_ran() {
|
||||
require_test_script_deps();
|
||||
|
||||
let tmp = extract_project("inner_gtest");
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -420,9 +343,10 @@ fn test_inner_run_tests_google_tests_actually_ran() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(not(has_cmake), ignore = "cmake not found")]
|
||||
#[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")]
|
||||
#[cfg_attr(not(has_git), ignore = "git not found")]
|
||||
fn test_inner_run_tests_clean_flag_rebuilds() {
|
||||
require_test_script_deps();
|
||||
|
||||
let tmp = extract_project("inner_clean");
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -456,9 +380,10 @@ fn test_inner_run_tests_clean_flag_rebuilds() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(not(has_cmake), ignore = "cmake not found")]
|
||||
#[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")]
|
||||
#[cfg_attr(not(has_git), ignore = "git not found")]
|
||||
fn test_inner_run_tests_produces_test_binary() {
|
||||
require_test_script_deps();
|
||||
|
||||
let tmp = extract_project("inner_bin");
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -481,9 +406,10 @@ fn test_inner_run_tests_produces_test_binary() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(not(has_cmake), ignore = "cmake not found")]
|
||||
#[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")]
|
||||
#[cfg_attr(not(has_git), ignore = "git not found")]
|
||||
fn test_inner_run_tests_idempotent() {
|
||||
require_test_script_deps();
|
||||
|
||||
let tmp = extract_project("inner_idem");
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -511,9 +437,9 @@ fn test_inner_run_tests_idempotent() {
|
||||
// ==========================================================================
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
#[cfg_attr(not(has_arduino_cli), ignore = "arduino-cli not found")]
|
||||
fn test_build_script_compiles_sketch() {
|
||||
require_build_script_deps();
|
||||
|
||||
let tmp = extract_project("build_test");
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -534,9 +460,9 @@ fn test_build_script_compiles_sketch() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
#[cfg_attr(not(has_arduino_cli), ignore = "arduino-cli not found")]
|
||||
fn test_build_script_produces_compilation_output() {
|
||||
require_build_script_deps();
|
||||
|
||||
let tmp = extract_project("compile_out");
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -566,9 +492,9 @@ fn test_build_script_produces_compilation_output() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
#[cfg_attr(not(has_arduino_cli), ignore = "arduino-cli not found")]
|
||||
fn test_build_script_idempotent() {
|
||||
require_build_script_deps();
|
||||
|
||||
let tmp = extract_project("build_idem");
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -592,10 +518,12 @@ fn test_build_script_idempotent() {
|
||||
// ==========================================================================
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
#[cfg_attr(not(has_cmake), ignore = "cmake not found")]
|
||||
#[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")]
|
||||
#[cfg_attr(not(has_git), ignore = "git not found")]
|
||||
#[cfg_attr(not(has_arduino_cli), ignore = "arduino-cli not found")]
|
||||
fn test_full_project_all_scripts_pass() {
|
||||
require_test_script_deps();
|
||||
require_build_script_deps();
|
||||
|
||||
let tmp = extract_project("full_e2e");
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -749,10 +677,12 @@ fn test_cmake_lists_fetches_google_test() {
|
||||
fn test_scripts_all_reference_anvil_toml() {
|
||||
let tmp = extract_project("toml_refs");
|
||||
|
||||
// Build and upload scripts must read .anvil.toml for configuration
|
||||
// Build and upload scripts must read .anvil.toml for configuration.
|
||||
// On Windows, build.bat is a thin wrapper that calls build.ps1,
|
||||
// so we check the .ps1 file for content.
|
||||
let config_scripts = vec![
|
||||
"build.sh",
|
||||
"build.bat",
|
||||
"build.ps1",
|
||||
"upload.sh",
|
||||
"upload.bat",
|
||||
];
|
||||
@@ -777,9 +707,11 @@ fn test_scripts_all_reference_anvil_toml() {
|
||||
fn test_scripts_invoke_arduino_cli_not_anvil() {
|
||||
let tmp = extract_project("no_anvil_dep");
|
||||
|
||||
// Build/upload/monitor scripts must invoke arduino-cli directly
|
||||
// Build/upload/monitor scripts must invoke arduino-cli directly.
|
||||
// On Windows, build.bat is a thin wrapper calling build.ps1,
|
||||
// so we check the .ps1 file for content.
|
||||
let scripts = vec![
|
||||
"build.sh", "build.bat",
|
||||
"build.sh", "build.ps1",
|
||||
"upload.sh", "upload.bat",
|
||||
"monitor.sh", "monitor.bat",
|
||||
];
|
||||
@@ -829,6 +761,10 @@ fn test_scripts_invoke_arduino_cli_not_anvil() {
|
||||
|| trimmed.starts_with("Write-Host")
|
||||
|| trimmed.starts_with("Write-Error")
|
||||
|| trimmed.starts_with("Write-Warning")
|
||||
|| trimmed.starts_with("Fail ")
|
||||
|| trimmed.starts_with("Fail(")
|
||||
|| trimmed.starts_with("Fail \"")
|
||||
|| trimmed.starts_with("Fail @")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -860,6 +796,7 @@ fn test_all_expected_scripts_exist() {
|
||||
let expected = vec![
|
||||
"build.sh",
|
||||
"build.bat",
|
||||
"build.ps1",
|
||||
"upload.sh",
|
||||
"upload.bat",
|
||||
"monitor.sh",
|
||||
@@ -574,7 +574,9 @@ fn test_sh_scripts_have_toml_section_get() {
|
||||
|
||||
#[test]
|
||||
fn test_bat_scripts_have_section_parser() {
|
||||
// Batch scripts need section-aware TOML parsing for board profiles
|
||||
// Windows scripts need section-aware TOML parsing for board profiles.
|
||||
// build.bat delegates to build.ps1; upload.bat and monitor.bat may
|
||||
// still use batch-native parsing or their own .ps1 backends.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = TemplateContext {
|
||||
project_name: "bat_section".to_string(),
|
||||
@@ -585,12 +587,33 @@ fn test_bat_scripts_have_section_parser() {
|
||||
};
|
||||
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
||||
|
||||
for bat in &["build.bat", "upload.bat", "monitor.bat"] {
|
||||
let content = fs::read_to_string(tmp.path().join(bat)).unwrap();
|
||||
// Check each Windows script OR its PowerShell backend for section parsing
|
||||
let pairs: &[(&str, &str)] = &[
|
||||
("build.bat", "build.ps1"),
|
||||
("upload.bat", "upload.ps1"),
|
||||
("monitor.bat", "monitor.ps1"),
|
||||
];
|
||||
|
||||
for (bat, ps1) in pairs {
|
||||
let bat_path = tmp.path().join(bat);
|
||||
let ps1_path = tmp.path().join(ps1);
|
||||
|
||||
let has_parser = if ps1_path.exists() {
|
||||
// PowerShell backend handles TOML parsing
|
||||
let content = fs::read_to_string(&ps1_path).unwrap();
|
||||
content.contains("boards.") || content.contains("currentSection")
|
||||
} else if bat_path.exists() {
|
||||
// Batch does its own section parsing
|
||||
let content = fs::read_to_string(&bat_path).unwrap();
|
||||
content.contains("BOARD_SECTION") || content.contains("IN_SECTION")
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
assert!(
|
||||
content.contains("BOARD_SECTION") || content.contains("IN_SECTION"),
|
||||
"{} should have section parser for board profiles",
|
||||
bat
|
||||
has_parser,
|
||||
"{} (or {}) should have section parser for board profiles",
|
||||
bat, ps1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,7 +489,7 @@ fn test_refresh_freshly_extracted_is_up_to_date() {
|
||||
TemplateManager::extract("basic", reference.path(), &ctx).unwrap();
|
||||
|
||||
let refreshable = vec![
|
||||
"build.sh", "build.bat",
|
||||
"build.sh", "build.bat", "build.ps1",
|
||||
"upload.sh", "upload.bat",
|
||||
"monitor.sh", "monitor.bat",
|
||||
"test.sh", "test.bat",
|
||||
@@ -561,7 +561,7 @@ fn test_refresh_does_not_list_user_files() {
|
||||
];
|
||||
|
||||
let refreshable = vec![
|
||||
"build.sh", "build.bat",
|
||||
"build.sh", "build.bat", "build.ps1",
|
||||
"upload.sh", "upload.bat",
|
||||
"monitor.sh", "monitor.bat",
|
||||
"test.sh", "test.bat",
|
||||
@@ -644,12 +644,14 @@ fn test_scripts_read_default_board() {
|
||||
);
|
||||
}
|
||||
|
||||
for bat in &["build.bat", "upload.bat", "monitor.bat"] {
|
||||
let content = fs::read_to_string(tmp.path().join(bat)).unwrap();
|
||||
// build.bat is now a thin wrapper; build.ps1 has the real logic.
|
||||
// upload.bat and monitor.bat still have batch-native parsing.
|
||||
for script in &["build.ps1", "upload.bat", "monitor.bat"] {
|
||||
let content = fs::read_to_string(tmp.path().join(script)).unwrap();
|
||||
assert!(
|
||||
content.contains("DEFAULT_BOARD"),
|
||||
"{} should read default field into DEFAULT_BOARD",
|
||||
bat
|
||||
content.contains("DEFAULT_BOARD") || content.contains("DefaultBoard") || content.contains("default"),
|
||||
"{} should read default field for board selection",
|
||||
script
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -674,8 +676,9 @@ fn test_scripts_use_compiler_extra_flags_not_build() {
|
||||
};
|
||||
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
||||
|
||||
// build.bat is a thin wrapper; check build.ps1 for content
|
||||
let compile_scripts = vec![
|
||||
"build.sh", "build.bat",
|
||||
"build.sh", "build.ps1",
|
||||
"upload.sh", "upload.bat",
|
||||
];
|
||||
|
||||
@@ -743,8 +746,9 @@ fn test_script_errors_show_manual_fix() {
|
||||
};
|
||||
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
||||
|
||||
// build.bat is a thin wrapper; check build.ps1 for content
|
||||
let all_scripts = vec![
|
||||
"build.sh", "build.bat",
|
||||
"build.sh", "build.ps1",
|
||||
"upload.sh", "upload.bat",
|
||||
"monitor.sh", "monitor.bat",
|
||||
];
|
||||
@@ -752,7 +756,7 @@ fn test_script_errors_show_manual_fix() {
|
||||
for script in &all_scripts {
|
||||
let content = fs::read_to_string(tmp.path().join(script)).unwrap();
|
||||
assert!(
|
||||
content.contains("default = "),
|
||||
content.contains("default = ") || content.contains("default ="),
|
||||
"{} error messages should show the manual fix (default = \"...\")",
|
||||
script
|
||||
);
|
||||
@@ -773,8 +777,9 @@ fn test_script_errors_mention_arduino_cli() {
|
||||
};
|
||||
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
||||
|
||||
// build.bat is a thin wrapper; check build.ps1 for content
|
||||
let all_scripts = vec![
|
||||
"build.sh", "build.bat",
|
||||
"build.sh", "build.ps1",
|
||||
"upload.sh", "upload.bat",
|
||||
"monitor.sh", "monitor.bat",
|
||||
];
|
||||
@@ -803,8 +808,8 @@ fn test_script_errors_mention_toml_section_syntax() {
|
||||
};
|
||||
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
||||
|
||||
// build and upload scripts have both no-default and board-not-found errors
|
||||
for script in &["build.sh", "build.bat", "upload.sh", "upload.bat"] {
|
||||
// build.bat is a thin wrapper; check build.ps1 for content
|
||||
for script in &["build.sh", "build.ps1", "upload.sh", "upload.bat"] {
|
||||
let content = fs::read_to_string(tmp.path().join(script)).unwrap();
|
||||
assert!(
|
||||
content.contains("[boards."),
|
||||
@@ -932,11 +937,12 @@ fn test_build_scripts_autodiscover_driver_includes() {
|
||||
};
|
||||
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
||||
|
||||
// build.bat is a thin wrapper; check build.ps1 for content.
|
||||
// All four compile scripts must auto-discover lib/drivers/*
|
||||
for script in &["build.sh", "upload.sh", "build.bat", "upload.bat"] {
|
||||
for script in &["build.sh", "upload.sh", "build.ps1", "upload.bat"] {
|
||||
let content = fs::read_to_string(tmp.path().join(script)).unwrap();
|
||||
assert!(
|
||||
content.contains("lib/drivers") || content.contains("lib\\drivers"),
|
||||
content.contains("lib/drivers") || content.contains("lib\\drivers") || content.contains("lib\\\\drivers"),
|
||||
"{} must auto-discover lib/drivers/* for library include paths",
|
||||
script
|
||||
);
|
||||
|
||||
19
xtask/Cargo.toml
Normal file
19
xtask/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "xtask"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# CLI argument parsing
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
# Colors
|
||||
colored = "2.1"
|
||||
# Command existence check
|
||||
which = "5.0"
|
||||
667
xtask/src/main.rs
Normal file
667
xtask/src/main.rs
Normal file
@@ -0,0 +1,667 @@
|
||||
//! xtask -- build automation for Anvil.
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo xtask Build all platform binaries
|
||||
//! cargo xtask --check Check dependencies
|
||||
//! cargo xtask --fix Install missing dependencies
|
||||
//! cargo xtask --clean Remove zig/cargo cross-compile cache
|
||||
//! cargo xtask --suffix rc1 Build with version suffix (e.g. 1.0.0-rc1)
|
||||
//!
|
||||
//! Produces:
|
||||
//! release-artifacts/anvil-X.Y.Z-freebsd-x86_64.tar.gz (native)
|
||||
//! release-artifacts/anvil-X.Y.Z-linux-x86_64.tar.gz (via cargo-zigbuild)
|
||||
//! release-artifacts/anvil-X.Y.Z-windows-x86_64.zip (via cargo-zigbuild)
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Parser;
|
||||
use colored::*;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "xtask",
|
||||
about = "Build Anvil release binaries for all platforms",
|
||||
long_about = "Builds FreeBSD (native), Linux, and Windows binaries using cargo-zigbuild.\n\
|
||||
No VMs, no root, no containers required.\n\n\
|
||||
First time: cargo xtask --fix\n\
|
||||
Every release: cargo xtask"
|
||||
)]
|
||||
struct Args {
|
||||
/// Check dependencies and report what is missing
|
||||
#[arg(long)]
|
||||
check: bool,
|
||||
|
||||
/// Install missing dependencies (zig, cargo-zigbuild, rustup targets)
|
||||
#[arg(long)]
|
||||
fix: bool,
|
||||
|
||||
/// Remove cargo target directories for cross-compile targets
|
||||
#[arg(long)]
|
||||
clean: bool,
|
||||
|
||||
/// Version suffix (e.g. rc1 -> 1.0.0-rc1)
|
||||
#[arg(long, value_name = "SUFFIX")]
|
||||
suffix: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Targets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[allow(dead_code)]
|
||||
struct Target {
|
||||
name: &'static str,
|
||||
triple: &'static str,
|
||||
binary_name: &'static str,
|
||||
archive_ext: &'static str,
|
||||
}
|
||||
|
||||
const TARGETS: &[Target] = &[
|
||||
Target {
|
||||
name: "Linux x86_64",
|
||||
triple: "x86_64-unknown-linux-gnu",
|
||||
binary_name: "anvil",
|
||||
archive_ext: "tar.gz",
|
||||
},
|
||||
Target {
|
||||
name: "Windows x86_64",
|
||||
triple: "x86_64-pc-windows-gnu",
|
||||
binary_name: "anvil.exe",
|
||||
archive_ext: "zip",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn ok(msg: &str) {
|
||||
println!("{} {}", "ok".green(), msg);
|
||||
}
|
||||
|
||||
fn warn(msg: &str) {
|
||||
println!("{} {}", "warn".yellow(), msg);
|
||||
}
|
||||
|
||||
fn info(msg: &str) {
|
||||
println!("{} {}", "....".cyan(), msg);
|
||||
}
|
||||
|
||||
fn fail(msg: &str) {
|
||||
eprintln!("{} {}", "FAIL".red(), msg);
|
||||
}
|
||||
|
||||
fn header(msg: &str) {
|
||||
println!("\n{}\n", format!("=== {} ===", msg).bold());
|
||||
}
|
||||
|
||||
fn cmd_exists(cmd: &str) -> bool {
|
||||
which::which(cmd).is_ok()
|
||||
}
|
||||
|
||||
fn run(program: &str, args: &[&str], cwd: Option<&Path>) -> Result<()> {
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(args);
|
||||
if let Some(dir) = cwd {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
let status = cmd
|
||||
.status()
|
||||
.with_context(|| format!("Failed to run: {} {}", program, args.join(" ")))?;
|
||||
if !status.success() {
|
||||
bail!("{} {} failed with exit code: {}", program, args.join(" "), status);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_captured(program: &str, args: &[&str]) -> Result<String> {
|
||||
let output = Command::new(program)
|
||||
.args(args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.with_context(|| format!("Failed to run: {}", program))?;
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Find the workspace root.
|
||||
/// When run via `cargo xtask`, cargo sets CARGO_MANIFEST_DIR to the xtask
|
||||
/// package directory. The workspace root is one level up from there.
|
||||
fn workspace_root() -> Result<PathBuf> {
|
||||
// CARGO_MANIFEST_DIR points to xtask/ -- parent is the workspace root
|
||||
if let Ok(manifest_dir) = env::var("CARGO_MANIFEST_DIR") {
|
||||
let xtask_dir = PathBuf::from(manifest_dir);
|
||||
if let Some(parent) = xtask_dir.parent() {
|
||||
if parent.join("Cargo.toml").exists() {
|
||||
return Ok(parent.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: walk up from current dir looking for a workspace Cargo.toml
|
||||
let mut dir = env::current_dir()?;
|
||||
loop {
|
||||
let cargo_toml = dir.join("Cargo.toml");
|
||||
if cargo_toml.exists() {
|
||||
// Check if it's a workspace (contains [workspace])
|
||||
let contents = fs::read_to_string(&cargo_toml).unwrap_or_default();
|
||||
if contents.contains("[workspace]") {
|
||||
return Ok(dir);
|
||||
}
|
||||
}
|
||||
if !dir.pop() {
|
||||
bail!("Could not find workspace root (no Cargo.toml with [workspace] found)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read version from workspace Cargo.toml
|
||||
fn read_version(workspace: &Path) -> Result<String> {
|
||||
let cargo_toml = fs::read_to_string(workspace.join("Cargo.toml"))
|
||||
.context("Failed to read Cargo.toml")?;
|
||||
for line in cargo_toml.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("version") && line.contains('=') {
|
||||
if let Some(v) = line.split('=').nth(1) {
|
||||
let v = v.trim().trim_matches('"');
|
||||
return Ok(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
bail!("Could not read version from Cargo.toml")
|
||||
}
|
||||
|
||||
/// Detect host OS
|
||||
fn host_os() -> &'static str {
|
||||
if cfg!(target_os = "freebsd") {
|
||||
"freebsd"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"linux"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"windows"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/// Archive binary into release-artifacts/
|
||||
fn package_binary(
|
||||
workspace: &Path,
|
||||
triple: &str,
|
||||
binary_name: &str,
|
||||
archive_name: &str,
|
||||
archive_ext: &str,
|
||||
) -> Result<()> {
|
||||
let artifacts = workspace.join("release-artifacts");
|
||||
fs::create_dir_all(&artifacts)?;
|
||||
|
||||
let binary_path = workspace
|
||||
.join("target")
|
||||
.join(triple)
|
||||
.join("release")
|
||||
.join(binary_name);
|
||||
|
||||
if !binary_path.exists() {
|
||||
// Show what's actually in the target dir to help diagnose
|
||||
let target_dir = binary_path.parent().unwrap();
|
||||
let entries: Vec<String> = fs::read_dir(target_dir)
|
||||
.map(|rd| rd.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.collect())
|
||||
.unwrap_or_default();
|
||||
bail!(
|
||||
"Binary not found: {}\n Contents of {}:\n {}",
|
||||
binary_path.display(),
|
||||
target_dir.display(),
|
||||
if entries.is_empty() { "(empty)".to_string() } else { entries.join(", ") }
|
||||
);
|
||||
}
|
||||
|
||||
match archive_ext {
|
||||
"tar.gz" => {
|
||||
let archive = artifacts.join(format!("{}.tar.gz", archive_name));
|
||||
run(
|
||||
"tar",
|
||||
&[
|
||||
"-czf",
|
||||
archive.to_str().unwrap(),
|
||||
"-C",
|
||||
binary_path.parent().unwrap().to_str().unwrap(),
|
||||
binary_name,
|
||||
],
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
"zip" => {
|
||||
let archive = artifacts.join(format!("{}.zip", archive_name));
|
||||
// -j = junk paths (store just the filename, not the full path)
|
||||
run(
|
||||
"zip",
|
||||
&["-q", "-j", archive.to_str().unwrap(), binary_path.to_str().unwrap()],
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
_ => bail!("Unknown archive format: {}", archive_ext),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Package native FreeBSD binary from target/release/
|
||||
fn package_native(workspace: &Path, archive_name: &str) -> Result<()> {
|
||||
let artifacts = workspace.join("release-artifacts");
|
||||
fs::create_dir_all(&artifacts)?;
|
||||
|
||||
let binary_path = workspace.join("target").join("release").join("anvil");
|
||||
if !binary_path.exists() {
|
||||
bail!("Native binary not found: {}", binary_path.display());
|
||||
}
|
||||
|
||||
let archive = artifacts.join(format!("{}.tar.gz", archive_name));
|
||||
run(
|
||||
"tar",
|
||||
&[
|
||||
"-czf",
|
||||
archive.to_str().unwrap(),
|
||||
"-C",
|
||||
binary_path.parent().unwrap().to_str().unwrap(),
|
||||
"anvil",
|
||||
],
|
||||
None,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate SHA256SUMS in release-artifacts/
|
||||
fn generate_checksums(workspace: &Path) -> Result<()> {
|
||||
let artifacts = workspace.join("release-artifacts");
|
||||
let prev = env::current_dir()?;
|
||||
env::set_current_dir(&artifacts)?;
|
||||
|
||||
// Collect artifact files
|
||||
let mut files: Vec<String> = fs::read_dir(&artifacts)?
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if name != "SHA256SUMS" {
|
||||
Some(name)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
if cmd_exists("sha256sum") {
|
||||
let mut args = vec!["--"];
|
||||
for f in &files {
|
||||
args.push(f.as_str());
|
||||
}
|
||||
let output = Command::new("sha256sum")
|
||||
.args(&args)
|
||||
.output()?;
|
||||
fs::write("SHA256SUMS", &output.stdout)?;
|
||||
} else if cmd_exists("shasum") {
|
||||
let mut args = vec!["-a", "256", "--"];
|
||||
for f in &files {
|
||||
args.push(f.as_str());
|
||||
}
|
||||
let output = Command::new("shasum")
|
||||
.args(&args)
|
||||
.output()?;
|
||||
fs::write("SHA256SUMS", &output.stdout)?;
|
||||
}
|
||||
|
||||
let checksums = fs::read_to_string("SHA256SUMS").unwrap_or_default();
|
||||
print!("{}", checksums);
|
||||
|
||||
env::set_current_dir(prev)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn run_check(_workspace: &Path) -> Result<bool> {
|
||||
header("Checking dependencies");
|
||||
let mut missing = 0;
|
||||
|
||||
// zig
|
||||
if cmd_exists("zig") {
|
||||
let ver = run_captured("zig", &["version"]).unwrap_or_default();
|
||||
ok(&format!("zig {}", ver));
|
||||
} else {
|
||||
warn("zig -- not found");
|
||||
println!(" {}", "sudo pkg install zig (FreeBSD) or apt install zig (Linux)");
|
||||
missing += 1;
|
||||
}
|
||||
|
||||
// zip (for Windows archive)
|
||||
if cmd_exists("zip") {
|
||||
ok("zip");
|
||||
} else {
|
||||
warn("zip -- not found");
|
||||
println!(" {}", "sudo pkg install zip (FreeBSD) or apt install zip (Linux)");
|
||||
missing += 1;
|
||||
}
|
||||
|
||||
// cargo-zigbuild
|
||||
if cmd_exists("cargo-zigbuild") {
|
||||
let ver = run_captured("cargo-zigbuild", &["--version"])
|
||||
.unwrap_or_default();
|
||||
ok(&format!("cargo-zigbuild {}", ver.replace("cargo-zigbuild ", "")));
|
||||
} else {
|
||||
warn("cargo-zigbuild -- not found");
|
||||
println!(" {}", "cargo install cargo-zigbuild");
|
||||
missing += 1;
|
||||
}
|
||||
|
||||
// rustup targets
|
||||
if cmd_exists("rustup") {
|
||||
let targets = run_captured("rustup", &["target", "list", "--installed"])
|
||||
.unwrap_or_default();
|
||||
for t in TARGETS {
|
||||
if targets.contains(t.triple) {
|
||||
ok(&format!("rustup target: {}", t.triple));
|
||||
} else {
|
||||
warn(&format!("rustup target: {} -- not installed", t.triple));
|
||||
println!(" rustup target add {}", t.triple);
|
||||
missing += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn("rustup -- not found");
|
||||
println!(" {}", "curl https://sh.rustup.rs -sSf | sh");
|
||||
missing += 1;
|
||||
}
|
||||
|
||||
// Host platform note
|
||||
let host = host_os();
|
||||
if host == "linux" {
|
||||
println!();
|
||||
warn("Running on Linux -- FreeBSD binary cannot be produced.");
|
||||
println!(" Boot into FreeBSD and run 'cargo xtask' for the FreeBSD binary.");
|
||||
} else if host != "freebsd" {
|
||||
println!();
|
||||
warn(&format!("Unsupported host OS: {}", host));
|
||||
}
|
||||
|
||||
println!();
|
||||
if missing == 0 {
|
||||
ok("All dependencies satisfied -- ready to build!");
|
||||
Ok(true)
|
||||
} else {
|
||||
warn(&format!("{} dependency/dependencies missing. Run: cargo xtask --fix", missing));
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn run_fix() -> Result<()> {
|
||||
header("Installing dependencies");
|
||||
|
||||
let host = host_os();
|
||||
|
||||
// zig
|
||||
if cmd_exists("zig") {
|
||||
ok("zig already installed");
|
||||
} else {
|
||||
info("Installing zig...");
|
||||
match host {
|
||||
"freebsd" => run("sudo", &["pkg", "install", "-y", "zig"], None)?,
|
||||
"linux" => {
|
||||
// Try common package managers
|
||||
if cmd_exists("apt-get") {
|
||||
run("sudo", &["apt-get", "install", "-y", "zig"], None)?;
|
||||
} else if cmd_exists("dnf") {
|
||||
run("sudo", &["dnf", "install", "-y", "zig"], None)?;
|
||||
} else {
|
||||
bail!("Could not install zig automatically. Install manually: https://ziglang.org/download/");
|
||||
}
|
||||
}
|
||||
_ => bail!("Cannot auto-install zig on {}. Install manually: https://ziglang.org/download/", host),
|
||||
}
|
||||
ok("zig installed");
|
||||
}
|
||||
|
||||
// zip (for Windows archive packaging)
|
||||
if cmd_exists("zip") {
|
||||
ok("zip already installed");
|
||||
} else {
|
||||
info("Installing zip...");
|
||||
match host {
|
||||
"freebsd" => run("sudo", &["pkg", "install", "-y", "zip"], None)?,
|
||||
"linux" => {
|
||||
if cmd_exists("apt-get") {
|
||||
run("sudo", &["apt-get", "install", "-y", "zip"], None)?;
|
||||
} else if cmd_exists("dnf") {
|
||||
run("sudo", &["dnf", "install", "-y", "zip"], None)?;
|
||||
} else {
|
||||
bail!("Could not install zip automatically. Install it manually.");
|
||||
}
|
||||
}
|
||||
_ => bail!("Cannot auto-install zip on {}. Install it manually.", host),
|
||||
}
|
||||
ok("zip installed");
|
||||
}
|
||||
|
||||
// cargo-zigbuild
|
||||
if cmd_exists("cargo-zigbuild") {
|
||||
ok("cargo-zigbuild already installed");
|
||||
} else {
|
||||
info("Installing cargo-zigbuild...");
|
||||
run("cargo", &["install", "cargo-zigbuild"], None)?;
|
||||
ok("cargo-zigbuild installed");
|
||||
}
|
||||
|
||||
// rustup targets
|
||||
if cmd_exists("rustup") {
|
||||
let installed = run_captured("rustup", &["target", "list", "--installed"])
|
||||
.unwrap_or_default();
|
||||
for t in TARGETS {
|
||||
if installed.contains(t.triple) {
|
||||
ok(&format!("rustup target {} already installed", t.triple));
|
||||
} else {
|
||||
info(&format!("Adding rustup target {}...", t.triple));
|
||||
run("rustup", &["target", "add", t.triple], None)?;
|
||||
ok(&format!("rustup target {} added", t.triple));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
bail!("rustup not found -- install it first: curl https://sh.rustup.rs -sSf | sh");
|
||||
}
|
||||
|
||||
println!();
|
||||
ok("All dependencies installed. Run: cargo xtask");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clean
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn run_clean(workspace: &Path) -> Result<()> {
|
||||
header("Cleaning cross-compile artifacts");
|
||||
|
||||
for t in TARGETS {
|
||||
let target_dir = workspace.join("target").join(t.triple);
|
||||
if target_dir.exists() {
|
||||
info(&format!("Removing target/{}/...", t.triple));
|
||||
fs::remove_dir_all(&target_dir)?;
|
||||
ok(&format!("target/{} removed", t.triple));
|
||||
} else {
|
||||
ok(&format!("target/{} already clean", t.triple));
|
||||
}
|
||||
}
|
||||
|
||||
let artifacts = workspace.join("release-artifacts");
|
||||
if artifacts.exists() {
|
||||
info("Removing release-artifacts/...");
|
||||
fs::remove_dir_all(&artifacts)?;
|
||||
ok("release-artifacts removed");
|
||||
}
|
||||
|
||||
println!();
|
||||
ok("Clean complete.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn run_build(workspace: &Path, version: &str) -> Result<()> {
|
||||
let host = host_os();
|
||||
|
||||
println!();
|
||||
println!("{}", format!("Building Anvil v{} -- all platforms", version).bold());
|
||||
println!(" Version: {} (from Cargo.toml)", version);
|
||||
println!(" Host: {}", host);
|
||||
|
||||
// Clean previous artifacts
|
||||
let artifacts = workspace.join("release-artifacts");
|
||||
if artifacts.exists() {
|
||||
fs::remove_dir_all(&artifacts)?;
|
||||
}
|
||||
fs::create_dir_all(&artifacts)?;
|
||||
|
||||
// -- FreeBSD native ----------------------------------------------------
|
||||
if host == "freebsd" {
|
||||
header("FreeBSD x86_64");
|
||||
info("Building...");
|
||||
run("cargo", &["build", "--release"], Some(workspace))?;
|
||||
let archive_name = format!("anvil-{}-freebsd-x86_64", version);
|
||||
package_native(workspace, &archive_name)?;
|
||||
ok(&format!("{}.tar.gz", archive_name));
|
||||
} else if host == "linux" {
|
||||
println!();
|
||||
warn("Running on Linux -- FreeBSD binary cannot be produced.");
|
||||
println!(" Boot into FreeBSD and run 'cargo xtask' for the FreeBSD binary.");
|
||||
println!();
|
||||
}
|
||||
|
||||
// -- Linux x86_64 via cargo-zigbuild -----------------------------------
|
||||
header("Linux x86_64 (via cargo-zigbuild)");
|
||||
info("Building...");
|
||||
run(
|
||||
"cargo",
|
||||
&["zigbuild", "--release", "--target", "x86_64-unknown-linux-gnu"],
|
||||
Some(workspace),
|
||||
)?;
|
||||
let archive_name = format!("anvil-{}-linux-x86_64", version);
|
||||
package_binary(
|
||||
workspace,
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"anvil",
|
||||
&archive_name,
|
||||
"tar.gz",
|
||||
)?;
|
||||
ok(&format!("{}.tar.gz", archive_name));
|
||||
|
||||
// -- Windows x86_64 via cargo-zigbuild ---------------------------------
|
||||
header("Windows x86_64 (via cargo-zigbuild)");
|
||||
info("Building...");
|
||||
run(
|
||||
"cargo",
|
||||
&["zigbuild", "--release", "--target", "x86_64-pc-windows-gnu"],
|
||||
Some(workspace),
|
||||
)?;
|
||||
let archive_name = format!("anvil-{}-windows-x86_64", version);
|
||||
package_binary(
|
||||
workspace,
|
||||
"x86_64-pc-windows-gnu",
|
||||
"anvil.exe",
|
||||
&archive_name,
|
||||
"zip",
|
||||
)?;
|
||||
ok(&format!("{}.zip", archive_name));
|
||||
|
||||
// -- Checksums ---------------------------------------------------------
|
||||
header("Checksums");
|
||||
generate_checksums(workspace)?;
|
||||
|
||||
// -- Summary -----------------------------------------------------------
|
||||
println!();
|
||||
println!("{}", format!("=== Anvil v{} ready ===", version).bold().green());
|
||||
println!();
|
||||
for entry in fs::read_dir(&artifacts)?.flatten() {
|
||||
let meta = entry.metadata()?;
|
||||
println!(
|
||||
" {} ({:.1} MB)",
|
||||
entry.file_name().to_string_lossy(),
|
||||
meta.len() as f64 / 1_048_576.0
|
||||
);
|
||||
}
|
||||
println!();
|
||||
println!("Upload to Gitea:");
|
||||
println!(" 1. Releases -> New Release -> Tag: v{}", version);
|
||||
println!(" 2. Drag and drop everything from release-artifacts/");
|
||||
println!(" 3. Save");
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
let workspace = workspace_root().expect("Could not find workspace root");
|
||||
|
||||
let result = if args.clean {
|
||||
run_clean(&workspace)
|
||||
} else if args.check {
|
||||
match run_check(&workspace) {
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => std::process::exit(1),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
} else if args.fix {
|
||||
run_fix()
|
||||
} else {
|
||||
// Build -- check deps first
|
||||
match run_check(&workspace) {
|
||||
Ok(false) => {
|
||||
println!();
|
||||
fail("Dependencies missing.");
|
||||
fail("Run: cargo xtask --fix");
|
||||
println!();
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(e) => {
|
||||
fail(&format!("Dependency check failed: {}", e));
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(true) => {}
|
||||
}
|
||||
|
||||
let base_version =
|
||||
read_version(&workspace).expect("Could not read version from Cargo.toml");
|
||||
let version = match &args.suffix {
|
||||
Some(s) => format!("{}-{}", base_version, s.trim_start_matches('v')),
|
||||
None => base_version,
|
||||
};
|
||||
|
||||
run_build(&workspace, &version)
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
fail(&format!("{:#}", e));
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user