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

226
src/project/config.rs Normal file
View File

@@ -0,0 +1,226 @@
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::fs;
use anyhow::{Result, Context, bail};
use crate::version::ANVIL_VERSION;
pub const CONFIG_FILENAME: &str = ".anvil.toml";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ProjectConfig {
pub project: ProjectMeta,
pub build: BuildConfig,
pub monitor: MonitorConfig,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ProjectMeta {
pub name: String,
pub anvil_version: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BuildConfig {
pub fqbn: String,
pub warnings: String,
pub include_dirs: Vec<String>,
pub extra_flags: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MonitorConfig {
pub baud: u32,
}
impl ProjectConfig {
/// Create a new project config with sensible defaults.
pub fn new(name: &str) -> Self {
Self {
project: ProjectMeta {
name: name.to_string(),
anvil_version: ANVIL_VERSION.to_string(),
},
build: BuildConfig {
fqbn: "arduino:avr:uno".to_string(),
warnings: "more".to_string(),
include_dirs: vec!["lib/hal".to_string(), "lib/app".to_string()],
extra_flags: vec!["-Werror".to_string()],
},
monitor: MonitorConfig {
baud: 115200,
},
}
}
/// Load config from a project directory.
pub fn load(project_root: &Path) -> Result<Self> {
let config_path = project_root.join(CONFIG_FILENAME);
if !config_path.exists() {
bail!(
"Not an Anvil project (missing {}).\n\
Create one with: anvil new <name>",
CONFIG_FILENAME
);
}
let contents = fs::read_to_string(&config_path)
.context(format!("Failed to read {}", config_path.display()))?;
let config: ProjectConfig = toml::from_str(&contents)
.context(format!("Failed to parse {}", config_path.display()))?;
Ok(config)
}
/// Save config to a project directory.
pub fn save(&self, project_root: &Path) -> Result<()> {
let config_path = project_root.join(CONFIG_FILENAME);
let contents = toml::to_string_pretty(self)
.context("Failed to serialize config")?;
fs::write(&config_path, contents)
.context(format!("Failed to write {}", config_path.display()))?;
Ok(())
}
/// Walk up from a directory to find the project root containing .anvil.toml.
pub fn find_project_root(start: &Path) -> Result<PathBuf> {
let mut dir = if start.is_absolute() {
start.to_path_buf()
} else {
std::env::current_dir()?.join(start)
};
for _ in 0..10 {
if dir.join(CONFIG_FILENAME).exists() {
return Ok(dir);
}
match dir.parent() {
Some(parent) => dir = parent.to_path_buf(),
None => break,
}
}
bail!(
"No {} found in {} or any parent directory.\n\
Create a project with: anvil new <name>",
CONFIG_FILENAME,
start.display()
);
}
/// Resolve include directories to absolute paths relative to project root.
pub fn resolve_include_flags(&self, project_root: &Path) -> Vec<String> {
let mut flags = Vec::new();
for dir in &self.build.include_dirs {
let abs = project_root.join(dir);
if abs.is_dir() {
flags.push(format!("-I{}", abs.display()));
}
}
flags
}
/// Build the full extra_flags string for arduino-cli.
pub fn extra_flags_string(&self, project_root: &Path) -> String {
let mut parts = self.resolve_include_flags(project_root);
for flag in &self.build.extra_flags {
parts.push(flag.clone());
}
parts.join(" ")
}
}
impl Default for ProjectConfig {
fn default() -> Self {
Self::new("untitled")
}
}
/// Return the Anvil home directory (~/.anvil).
pub fn anvil_home() -> Result<PathBuf> {
let home = dirs::home_dir()
.context("Could not determine home directory")?;
let anvil_dir = home.join(".anvil");
fs::create_dir_all(&anvil_dir)?;
Ok(anvil_dir)
}
/// Return the build cache directory (~/.anvil/builds).
pub fn build_cache_dir() -> Result<PathBuf> {
let dir = anvil_home()?.join("builds");
fs::create_dir_all(&dir)?;
Ok(dir)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_new_config_defaults() {
let config = ProjectConfig::new("test_project");
assert_eq!(config.project.name, "test_project");
assert_eq!(config.build.fqbn, "arduino:avr:uno");
assert_eq!(config.monitor.baud, 115200);
assert!(config.build.include_dirs.contains(&"lib/hal".to_string()));
}
#[test]
fn test_save_and_load() {
let tmp = TempDir::new().unwrap();
let config = ProjectConfig::new("roundtrip");
config.save(tmp.path()).unwrap();
let loaded = ProjectConfig::load(tmp.path()).unwrap();
assert_eq!(loaded.project.name, "roundtrip");
assert_eq!(loaded.build.fqbn, config.build.fqbn);
assert_eq!(loaded.monitor.baud, config.monitor.baud);
}
#[test]
fn test_find_project_root() {
let tmp = TempDir::new().unwrap();
let config = ProjectConfig::new("finder");
config.save(tmp.path()).unwrap();
// Create a subdirectory and search from there
let sub = tmp.path().join("sketch").join("deep");
fs::create_dir_all(&sub).unwrap();
let found = ProjectConfig::find_project_root(&sub).unwrap();
assert_eq!(found, tmp.path());
}
#[test]
fn test_find_project_root_not_found() {
let tmp = TempDir::new().unwrap();
let result = ProjectConfig::find_project_root(tmp.path());
assert!(result.is_err());
}
#[test]
fn test_resolve_include_flags() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("lib/hal")).unwrap();
fs::create_dir_all(tmp.path().join("lib/app")).unwrap();
let config = ProjectConfig::new("includes");
let flags = config.resolve_include_flags(tmp.path());
assert_eq!(flags.len(), 2);
assert!(flags[0].starts_with("-I"));
assert!(flags[0].contains("lib"));
}
#[test]
fn test_extra_flags_string() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("lib/hal")).unwrap();
fs::create_dir_all(tmp.path().join("lib/app")).unwrap();
let config = ProjectConfig::new("flags");
let flags = config.extra_flags_string(tmp.path());
assert!(flags.contains("-Werror"));
assert!(flags.contains("-I"));
}
}