Compare commits

..

8 Commits

Author SHA1 Message Date
Eric Ratliff
6fe81769a3 Cross-compile build works on Windows
Some checks failed
CI / Test (Linux) (push) Has been cancelled
CI / Test (Windows MSVC) (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
2026-03-17 15:21:13 -05:00
Eric Ratliff
aa0d0a33f5 Added a potential roadmap file
Some checks failed
CI / Test (Linux) (push) Has been cancelled
CI / Test (Windows MSVC) (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
2026-03-17 08:37:46 -05:00
Eric Ratliff
e7995de547 feat(xtask): cross-platform release build via cargo-zigbuild
Some checks failed
CI / Test (Linux) (push) Has been cancelled
CI / Test (Windows MSVC) (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
Replace build-all.sh shell script with a Rust xtask workspace member.
Uses cargo-zigbuild + zig as a universal cross-linker -- no VMs, no
containers, no root required.

Produces all three release binaries from a single FreeBSD machine:
  anvil-X.Y.Z-freebsd-x86_64.tar.gz  (native cargo build)
  anvil-X.Y.Z-linux-x86_64.tar.gz    (cargo zigbuild)
  anvil-X.Y.Z-windows-x86_64.zip     (cargo zigbuild)

Commands:
  cargo xtask --fix     install zig, zip, cargo-zigbuild, rustup targets
  cargo xtask --check   verify all dependencies
  cargo xtask           build all three binaries + SHA256SUMS
  cargo xtask --clean   remove cross-compile artifacts
  cargo xtask --suffix rc1  build with version suffix

Also converts Cargo.toml to a workspace (members: anvil, xtask).
build-all.sh retained as a thin wrapper around cargo xtask.
2026-03-16 22:48:38 -05:00
Eric Ratliff
13ab202880 Able to build the FreeBSD binary
Some checks failed
CI / Format (push) Has been cancelled
CI / Test (Linux) (push) Has been cancelled
CI / Test (Windows MSVC) (push) Has been cancelled
CI / Clippy (push) Has been cancelled
2026-03-16 18:07:20 -05:00
Eric Ratliff
bb4b2f4162 feat(freebsd): full FreeBSD 15.0 compatibility
Some checks failed
CI / Test (Linux) (push) Has been cancelled
CI / Test (Windows MSVC) (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
- board: detect cuaU* serial ports in fallback scanner, filter .init/.lock nodes
- board: fall through to OS scan when arduino-cli returns empty (serial-discovery
  uses unsupported Linux syscalls on FreeBSD)
- board: recognize cuaU* port names as USB in is_usb()
- upload.sh: fix silent exit under set -euo pipefail when vid_pid grep finds
  nothing (|| true on LOCAL_PORT and LOCAL_VID_PID greps)
- upload.sh: add cuaU to auto-detect port pattern
- doctor: detect dialer and operator group membership, offer --fix for both
- doctor: detect avr-size installed but not on PATH, offer --fix to add to
  shell rc file via detect_shell_rc()
- doctor: context-aware hardware section (suppress misleading messages when
  groups not yet applied)
- doctor: FreeBSD-specific troubleshooting hints throughout
- devices: suppress arduino-cli board detection section on FreeBSD (broken
  due to serial-discovery syscall issues)
- devices: always show USB hub tip on FreeBSD
- devices: FreeBSD-specific troubleshooting checklist with sequential numbering
  on all platforms
2026-03-16 17:43:45 -05:00
Eric Ratliff
d86c79b9cb All tests pass, all warnings resolved
Some checks failed
CI / Test (Linux) (push) Has been cancelled
CI / Test (Windows MSVC) (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
2026-02-23 13:08:11 -06:00
Eric Ratliff
a517fba88a WSL cargo test is now working 2026-02-23 12:55:28 -06:00
Eric Ratliff
3570777a0d Looking for dependencies on test for a better UX 2026-02-23 12:42:43 -06:00
18 changed files with 1558 additions and 392 deletions

3
.cargo/config.toml Normal file
View File

@@ -0,0 +1,3 @@
# This alias makes `cargo xtask` work -- it runs the xtask workspace member.
[alias]
xtask = "run --package xtask --"

12
Cargo.lock generated
View File

@@ -63,7 +63,7 @@ dependencies = [
[[package]]
name = "anvil"
version = "1.0.0"
version = "1.0.1-alpha1"
dependencies = [
"anyhow",
"assert_cmd",
@@ -1072,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.1-alpha1"
dependencies = [
"anyhow",
"clap",
"colored",
"which",
]
[[package]]
name = "zmij"
version = "1.0.21"

View File

@@ -1,6 +1,10 @@
[workspace]
members = [".", "xtask"]
default-members = ["."]
[package]
name = "anvil"
version = "1.0.0"
version = "1.0.1-alpha1"
edition = "2021"
authors = ["Eric Ratliff <eric@nxlearn.net>"]
description = "Arduino project generator and build tool - forges clean embedded projects"

133
README.md
View File

@@ -377,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
@@ -447,6 +399,85 @@ 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

View File

@@ -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
View 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");
}
}

14
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,14 @@
v1.1.0 — Template richness + uninstall
- Template metadata (template.toml with descriptions, file counts, test counts)
- anvil uninstall (remove arduino-cli, AVR core, etc.)
- More templates: servo, i2c, pid-control
v1.2.0 — Community library ecosystem
- Remote library registry (packages.nxgit.dev)
- anvil add nexus/sensors/ultrasonic
- anvil search, anvil update
- Versioned library dependencies in .anvil.toml
v1.3.0 — Developer experience
- Windows MSI installer (cargo-wix)
- GUI (Tauri) for teams uncomfortable with terminals

View File

@@ -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,12 +91,19 @@ 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) {
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)?");
{
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!(" 3. Check kernel log: dmesg | tail -20");
println!(" 4. Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'");
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;
}

View File

@@ -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)");

View File

@@ -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 {
// 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 serial ports {}",
" {} no board detected {}",
"na".yellow(),
"(plug in a board to detect)".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)
}

View File

@@ -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(),

View File

@@ -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() {

View File

@@ -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 ""

View File

@@ -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}"

View File

@@ -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

View File

@@ -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.
// ==========================================================================
@@ -33,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,
}
@@ -122,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" }
@@ -283,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)]
@@ -306,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)]
@@ -338,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)]
@@ -367,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)]
@@ -390,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)]
@@ -421,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)]
@@ -457,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)]
@@ -482,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)]
@@ -513,9 +438,8 @@ 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)]
@@ -536,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)]
@@ -568,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)]
@@ -594,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)]

19
xtask/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "xtask"
version = "0.1.1-alpha1"
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"

758
xtask/src/main.rs Normal file
View File

@@ -0,0 +1,758 @@
//! 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 CROSS_TARGETS: &[Target] = &[
Target {
name: "FreeBSD x86_64",
triple: "x86_64-unknown-freebsd",
binary_name: "anvil",
archive_ext: "tar.gz",
},
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",
},
];
/// Returns cross-compile targets for the current host, skipping the native target.
fn cross_targets_for_host(host: &str) -> Vec<&'static Target> {
CROSS_TARGETS.iter().filter(|t| {
match host {
"freebsd" => t.triple != "x86_64-unknown-freebsd",
"linux" => t.triple != "x86_64-unknown-linux-gnu",
"windows" => t.triple != "x86_64-pc-windows-gnu",
_ => true,
}
}).collect()
}
// ---------------------------------------------------------------------------
// 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 {
match std::env::consts::OS {
"freebsd" => "freebsd",
"linux" => "linux",
"macos" => "macos",
"windows" => "windows",
_ => "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));
if cfg!(windows) {
let ps_cmd = format!(
"Compress-Archive -Path '{}' -DestinationPath '{}' -Force",
binary_path.display(),
archive.display()
);
run("powershell", &["-NoProfile", "-Command", &ps_cmd], None)?;
} else {
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!(" {}", match host_os() {
"freebsd" => "sudo pkg install zig",
"linux" => "sudo apt install zig (or: sudo dnf install zig)",
"windows" => "winget install zig.zig (or: choco install zig)",
_ => "see https://ziglang.org/download/",
});
missing += 1;
}
// zip -- not needed on Windows (PowerShell Compress-Archive is built in)
if !cfg!(windows) {
if cmd_exists("zip") {
ok("zip");
} else {
warn("zip -- not found");
println!(" {}", match host_os() {
"freebsd" => "sudo pkg install zip",
"linux" => "sudo apt install zip",
_ => "install zip",
});
missing += 1;
}
} else {
ok("zip (PowerShell Compress-Archive -- built in)");
}
// 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 cross_targets_for_host(host_os()) {
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 == "windows" {
println!();
ok("Windows host -- cargo-zigbuild produces Linux, FreeBSD, and Windows binaries");
} else if host == "linux" {
println!();
ok("Linux host -- cargo-zigbuild produces FreeBSD and Windows binaries");
println!(" (native Linux binary built with plain cargo)");
}
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
// ---------------------------------------------------------------------------
/// On Windows, reads the current Machine + User PATH from the registry and
/// returns a merged PATH string so the current process can find newly
/// installed tools without requiring a new terminal session.
#[cfg(windows)]
fn refresh_windows_path() -> Result<String> {
use std::process::Command;
// Read system PATH
let sys = Command::new("powershell")
.args(&[
"-NoProfile", "-Command",
"[System.Environment]::GetEnvironmentVariable('PATH','Machine')"
])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
// Read user PATH
let usr = Command::new("powershell")
.args(&[
"-NoProfile", "-Command",
"[System.Environment]::GetEnvironmentVariable('PATH','User')"
])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
Ok(format!("{};{}", sys, usr))
}
#[cfg(not(windows))]
fn refresh_windows_path() -> Result<String> {
bail!("refresh_windows_path called on non-Windows platform")
}
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/");
}
}
"windows" => {
if cmd_exists("winget") {
run("winget", &["install", "--id", "zig.zig", "-e", "--silent"], None)?;
// Refresh PATH in the current process so zig is findable immediately.
// winget updates the registry but the current session won't see it
// without this -- otherwise the user needs to open a new terminal.
if let Ok(new_path) = refresh_windows_path() {
unsafe {
std::env::set_var("PATH", &new_path);
}
}
if !cmd_exists("zig") {
println!();
warn("zig was installed but is not yet on PATH.");
println!(" Open a new terminal and run: cargo xtask");
println!(" (Windows PATH updates require a new shell session)");
}
} else if cmd_exists("choco") {
run("choco", &["install", "zig", "-y"], None)?;
} else {
bail!("Install zig via winget: winget install zig.zig\n Or download from: https://ziglang.org/download/");
}
}
_ => bail!("Cannot auto-install zig on {}. Install manually: https://ziglang.org/download/", host),
}
ok("zig installed");
}
// zip (not needed on Windows -- PowerShell Compress-Archive is built in)
if host != "windows" {
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");
}
} else {
ok("zip not needed on Windows (using PowerShell Compress-Archive)");
}
// 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 cross_targets_for_host(host) {
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 CROSS_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)?;
// -- Native binary (current host) -------------------------------------
let (native_triple, native_name, native_ext) = match host {
"freebsd" => ("freebsd-x86_64", "anvil", "tar.gz"),
"linux" => ("linux-x86_64", "anvil", "tar.gz"),
"windows" => ("windows-x86_64", "anvil.exe", "zip"),
_ => ("unknown", "anvil", "tar.gz"),
};
header(&format!("{} (native)", native_name.to_uppercase().replace("ANVIL", &format!("{} x86_64", host.to_uppercase().replace("FREEBSD", "FreeBSD").replace("LINUX", "Linux").replace("WINDOWS", "Windows")))));
info("Building...");
run("cargo", &["build", "--release"], Some(workspace))?;
let archive_name = format!("anvil-{}-{}", version, native_triple);
if native_ext == "zip" {
// Windows native -- package exe directly
let binary_path = workspace.join("target").join("release").join(native_name);
package_binary(workspace, "", native_name, &archive_name, "zip")?;
let _ = binary_path; // used inside package_binary
} else {
package_native(workspace, &archive_name)?;
}
ok(&format!("{}.{}", archive_name, native_ext));
// -- Cross-compile targets via cargo-zigbuild --------------------------
for t in cross_targets_for_host(host) {
let label = match t.triple {
"x86_64-unknown-freebsd" => "FreeBSD x86_64",
"x86_64-unknown-linux-gnu" => "Linux x86_64",
"x86_64-pc-windows-gnu" => "Windows x86_64",
other => other,
};
header(&format!("{} (via cargo-zigbuild)", label));
info("Building...");
run(
"cargo",
&["zigbuild", "--release", "--target", t.triple],
Some(workspace),
)?;
let suffix = t.triple
.replace("x86_64-unknown-", "")
.replace("x86_64-pc-", "")
.replace("-gnu", "")
.replace("freebsd", "freebsd-x86_64")
.replace("linux", "linux-x86_64")
.replace("windows", "windows-x86_64");
let archive_name = format!("anvil-{}-{}", version, suffix);
package_binary(workspace, t.triple, t.binary_name, &archive_name, t.archive_ext)?;
ok(&format!("{}.{}", archive_name, t.archive_ext));
}
// -- 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);
}
}