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

252
src/commands/new.rs Normal file
View File

@@ -0,0 +1,252 @@
use anyhow::{Result, bail};
use colored::*;
use std::path::PathBuf;
use crate::templates::{TemplateManager, TemplateContext};
use crate::version::ANVIL_VERSION;
pub fn list_templates() -> Result<()> {
println!("{}", "Available templates:".bright_cyan().bold());
println!();
for info in TemplateManager::list_templates() {
let marker = if info.is_default { " (default)" } else { "" };
println!(
" {}{}",
info.name.bright_white().bold(),
marker.bright_cyan()
);
println!(" {}", info.description);
println!();
}
println!("{}", "Usage:".bright_yellow().bold());
println!(" anvil new <project-name>");
println!(" anvil new <project-name> --template basic");
println!();
Ok(())
}
pub fn create_project(name: &str, template: Option<&str>) -> Result<()> {
// Validate project name
validate_project_name(name)?;
let project_path = PathBuf::from(name);
if project_path.exists() {
bail!(
"Directory already exists: {}\n\
Choose a different name or remove the existing directory.",
project_path.display()
);
}
let template_name = template.unwrap_or("basic");
if !TemplateManager::template_exists(template_name) {
println!(
"{}",
format!("Template '{}' not found.", template_name).red().bold()
);
println!();
list_templates()?;
bail!("Invalid template");
}
println!(
"{}",
format!("Creating Arduino project: {}", name)
.bright_green()
.bold()
);
println!("{}", format!("Template: {}", template_name).bright_cyan());
println!();
// Create project directory
std::fs::create_dir_all(&project_path)?;
// Extract template
println!("{}", "Extracting template files...".bright_yellow());
let context = TemplateContext {
project_name: name.to_string(),
anvil_version: ANVIL_VERSION.to_string(),
};
let file_count = TemplateManager::extract(template_name, &project_path, &context)?;
println!("{} Extracted {} files", "ok".green(), file_count);
// Make shell scripts executable on Unix
#[cfg(unix)]
{
make_executable(&project_path);
}
// Initialize git repository
init_git(&project_path, template_name);
// Print success
println!();
println!(
"{}",
"================================================================"
.bright_green()
);
println!(
"{}",
format!(" Project created: {}", name)
.bright_green()
.bold()
);
println!(
"{}",
"================================================================"
.bright_green()
);
println!();
print_next_steps(name);
Ok(())
}
fn validate_project_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("Project name cannot be empty");
}
if name.len() > 50 {
bail!("Project name must be 50 characters or less");
}
if !name.chars().next().unwrap().is_alphabetic() {
bail!("Project name must start with a letter");
}
let valid = name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_');
if !valid {
bail!(
"Invalid project name '{}'\n\
Names may contain: letters, numbers, hyphens, underscores\n\
Must start with a letter.\n\n\
Valid examples:\n\
\x20 blink\n\
\x20 my-sensor\n\
\x20 team1234_robot",
name
);
}
Ok(())
}
fn init_git(project_dir: &PathBuf, template_name: &str) {
println!("{}", "Initializing git repository...".bright_yellow());
// Check if git is available
if which::which("git").is_err() {
eprintln!(
"{} git not found, skipping repository initialization.",
"warn".yellow()
);
return;
}
let run = |args: &[&str]| -> bool {
std::process::Command::new("git")
.args(args)
.current_dir(project_dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
if !run(&["init"]) {
eprintln!("{} Failed to initialize git.", "warn".yellow());
return;
}
run(&["add", "."]);
let msg = format!(
"Initial commit from anvil new --template {}",
template_name
);
run(&["commit", "-m", &msg]);
println!("{} Git repository initialized", "ok".green());
}
#[cfg(unix)]
fn make_executable(project_dir: &PathBuf) {
use std::os::unix::fs::PermissionsExt;
let scripts = ["test/run_tests.sh"];
for script in &scripts {
let path = project_dir.join(script);
if path.exists() {
if let Ok(meta) = std::fs::metadata(&path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = std::fs::set_permissions(&path, perms);
}
}
}
}
fn print_next_steps(project_name: &str) {
println!("{}", "Next steps:".bright_yellow().bold());
println!(
" 1. {}",
format!("cd {}", project_name).bright_cyan()
);
println!(" 2. Check your system: {}", "anvil doctor".bright_cyan());
println!(
" 3. Find your board: {}",
"anvil devices".bright_cyan()
);
println!(
" 4. Build and upload: {}",
format!("anvil build {}", project_name).bright_cyan()
);
println!(
" 5. Build + monitor: {}",
format!("anvil build --monitor {}", project_name).bright_cyan()
);
println!();
println!(
" Run host tests: {}",
"cd test && ./run_tests.sh".bright_cyan()
);
println!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_name_valid() {
assert!(validate_project_name("blink").is_ok());
assert!(validate_project_name("my-sensor").is_ok());
assert!(validate_project_name("team1234_robot").is_ok());
}
#[test]
fn test_validate_name_empty() {
assert!(validate_project_name("").is_err());
}
#[test]
fn test_validate_name_starts_with_number() {
assert!(validate_project_name("123abc").is_err());
}
#[test]
fn test_validate_name_special_chars() {
assert!(validate_project_name("my project").is_err());
assert!(validate_project_name("my.project").is_err());
}
#[test]
fn test_validate_name_too_long() {
let long_name = "a".repeat(51);
assert!(validate_project_name(&long_name).is_err());
}
}