Anvil v1.0.0 -- Arduino build tool with HAL and test scaffolding

Single-binary CLI that scaffolds testable Arduino projects, compiles,
uploads, and monitors serial output. Templates embed a hardware
abstraction layer, Google Mock infrastructure, and CMake-based host
tests so application logic can be verified without hardware.

Commands: new, doctor, setup, devices, build, upload, monitor
39 Rust tests (21 unit, 18 integration)
Cross-platform: Linux and Windows
This commit is contained in:
Eric Ratliff
2026-02-15 11:16:17 -06:00
commit 3298844399
41 changed files with 4866 additions and 0 deletions

345
src/board/mod.rs Normal file
View File

@@ -0,0 +1,345 @@
use anyhow::{Result, bail};
use colored::*;
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Information about a detected serial port.
#[derive(Debug, Clone)]
pub struct PortInfo {
pub port_name: String,
pub protocol: String,
pub board_name: String,
pub fqbn: String,
}
/// JSON schema for `arduino-cli board list --format json`
#[derive(Debug, Deserialize)]
struct BoardListOutput {
#[serde(default)]
detected_ports: Vec<DetectedPort>,
}
#[derive(Debug, Deserialize)]
struct DetectedPort {
#[serde(default)]
port: Option<PortEntry>,
#[serde(default)]
matching_boards: Option<Vec<MatchingBoard>>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct PortEntry {
#[serde(default)]
address: String,
#[serde(default)]
protocol: String,
#[serde(default)]
protocol_label: String,
#[serde(default)]
properties: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct MatchingBoard {
#[serde(default)]
name: String,
#[serde(default)]
fqbn: String,
}
/// Enumerate serial ports via `arduino-cli board list --format json`.
/// Falls back to OS-level detection if arduino-cli is unavailable.
pub fn list_ports() -> Vec<PortInfo> {
if let Some(cli) = find_arduino_cli() {
if let Ok(ports) = list_ports_via_cli(&cli) {
return ports;
}
}
list_ports_fallback()
}
fn list_ports_via_cli(cli: &Path) -> Result<Vec<PortInfo>> {
let output = Command::new(cli)
.args(["board", "list", "--format", "json"])
.output()?;
if !output.status.success() {
bail!("arduino-cli board list failed");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: BoardListOutput = serde_json::from_str(&stdout)?;
let mut result = Vec::new();
for dp in parsed.detected_ports {
let port = match dp.port {
Some(p) => p,
None => continue,
};
if port.protocol != "serial" {
continue;
}
let (board_name, fqbn) = match dp.matching_boards {
Some(ref boards) if !boards.is_empty() => {
(boards[0].name.clone(), boards[0].fqbn.clone())
}
_ => ("Unknown".to_string(), String::new()),
};
result.push(PortInfo {
port_name: port.address,
protocol: port.protocol_label,
board_name,
fqbn,
});
}
Ok(result)
}
/// Fallback port detection when arduino-cli is not available.
fn list_ports_fallback() -> Vec<PortInfo> {
let mut result = Vec::new();
#[cfg(unix)]
{
use std::fs;
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") {
let path = format!("/dev/{}", name);
let board = if name.starts_with("ttyUSB") {
"Likely CH340/FTDI (run 'anvil setup' for full detection)"
} else {
"Likely Arduino (run 'anvil setup' for full detection)"
};
result.push(PortInfo {
port_name: path,
protocol: "serial".to_string(),
board_name: board.to_string(),
fqbn: String::new(),
});
}
}
}
}
#[cfg(windows)]
{
if let Ok(output) = Command::new("powershell")
.args([
"-NoProfile", "-Command",
"Get-CimInstance Win32_SerialPort | Select-Object DeviceID,Caption | ConvertTo-Json",
])
.output()
{
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if let Some(idx) = line.find("COM") {
let port = line[idx..].split('"').next().unwrap_or("").trim();
if !port.is_empty() {
result.push(PortInfo {
port_name: port.to_string(),
protocol: "serial".to_string(),
board_name: "Detected via WMI".to_string(),
fqbn: String::new(),
});
}
}
}
}
}
}
result
}
/// Auto-detect a single serial port.
pub fn auto_detect_port() -> Result<String> {
let ports = list_ports();
if ports.is_empty() {
bail!(
"No serial ports found. Is the board plugged in?\n\
Run: anvil devices"
);
}
if ports.len() == 1 {
return Ok(ports[0].port_name.clone());
}
eprintln!("{}", "Multiple serial ports detected:".yellow());
for p in &ports {
eprintln!(" {} ({})", p.port_name, p.board_name);
}
// Prefer a port with a recognized board
for p in &ports {
if !p.fqbn.is_empty() {
eprintln!(
"{}",
format!(
"Auto-selected {} ({}). Use -p to override.",
p.port_name, p.board_name
).yellow()
);
return Ok(p.port_name.clone());
}
}
let selected = ports[0].port_name.clone();
eprintln!(
"{}",
format!("Auto-selected {}. Use -p to override.", selected).yellow()
);
Ok(selected)
}
/// Print detailed port information.
pub fn print_port_details(ports: &[PortInfo]) {
if ports.is_empty() {
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'");
}
println!(" 5. Try a different USB port or cable");
return;
}
for port in ports {
println!(" {}", port.port_name.green().bold());
println!(" Board: {}", port.board_name);
if !port.fqbn.is_empty() {
println!(" FQBN: {}", port.fqbn);
}
if !port.protocol.is_empty() {
println!(" Protocol: {}", port.protocol);
}
#[cfg(unix)]
{
use std::fs::OpenOptions;
let path = std::path::Path::new(&port.port_name);
if path.exists() {
match OpenOptions::new().write(true).open(path) {
Ok(_) => {
println!(
" Permissions: {}",
"OK (writable)".green()
);
}
Err(_) => {
println!(
" Permissions: {}",
"NOT writable -- run: sudo usermod -aG dialout $USER"
.red()
);
}
}
}
}
println!();
}
}
/// Find arduino-cli in PATH or in ~/.anvil/bin.
pub fn find_arduino_cli() -> Option<PathBuf> {
if let Ok(path) = which::which("arduino-cli") {
return Some(path);
}
if let Ok(home) = crate::project::config::anvil_home() {
let name = if cfg!(target_os = "windows") {
"arduino-cli.exe"
} else {
"arduino-cli"
};
let bin = home.join("bin").join(name);
if bin.exists() {
return Some(bin);
}
}
None
}
/// Check if the arduino:avr core is installed.
pub fn is_avr_core_installed(cli_path: &Path) -> bool {
let output = Command::new(cli_path)
.args(["core", "list"])
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
stdout.contains("arduino:avr")
}
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_list_ports_does_not_panic() {
let _ports = list_ports();
}
#[test]
fn test_port_info_clone() {
let info = PortInfo {
port_name: "/dev/ttyUSB0".to_string(),
protocol: "serial".to_string(),
board_name: "Test".to_string(),
fqbn: "arduino:avr:uno".to_string(),
};
let cloned = info.clone();
assert_eq!(cloned.port_name, info.port_name);
}
#[test]
fn test_parse_empty_board_list() {
let json = r#"{"detected_ports": []}"#;
let parsed: BoardListOutput = serde_json::from_str(json).unwrap();
assert!(parsed.detected_ports.is_empty());
}
#[test]
fn test_parse_board_list_with_port() {
let json = r#"{
"detected_ports": [{
"port": {
"address": "/dev/ttyUSB0",
"protocol": "serial",
"protocol_label": "Serial Port (USB)"
},
"matching_boards": [{
"name": "Arduino Uno",
"fqbn": "arduino:avr:uno"
}]
}]
}"#;
let parsed: BoardListOutput = serde_json::from_str(json).unwrap();
assert_eq!(parsed.detected_ports.len(), 1);
let dp = &parsed.detected_ports[0];
assert_eq!(dp.port.as_ref().unwrap().address, "/dev/ttyUSB0");
let boards = dp.matching_boards.as_ref().unwrap();
assert_eq!(boards[0].name, "Arduino Uno");
}
}