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

234
src/templates/mod.rs Normal file
View File

@@ -0,0 +1,234 @@
use include_dir::{include_dir, Dir};
use std::path::Path;
use std::fs;
use anyhow::{Result, bail, Context};
use crate::version::ANVIL_VERSION;
static BASIC_TEMPLATE: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/basic");
pub struct TemplateContext {
pub project_name: String,
pub anvil_version: String,
}
pub struct TemplateManager;
impl TemplateManager {
pub fn template_exists(name: &str) -> bool {
matches!(name, "basic")
}
pub fn list_templates() -> Vec<TemplateInfo> {
vec![
TemplateInfo {
name: "basic".to_string(),
description: "Arduino project with HAL abstraction, mocks, and test infrastructure".to_string(),
is_default: true,
},
]
}
/// Extract a template into the output directory, applying variable
/// substitution and filename transformations.
pub fn extract(
template_name: &str,
output_dir: &Path,
context: &TemplateContext,
) -> Result<usize> {
let template_dir = match template_name {
"basic" => &BASIC_TEMPLATE,
_ => bail!("Unknown template: {}", template_name),
};
let count = extract_dir(template_dir, output_dir, "", context)?;
Ok(count)
}
}
pub struct TemplateInfo {
pub name: String,
pub description: String,
pub is_default: bool,
}
/// Recursively extract a directory from the embedded template.
fn extract_dir(
source: &Dir<'_>,
output_base: &Path,
relative_prefix: &str,
context: &TemplateContext,
) -> Result<usize> {
let mut count = 0;
for file in source.files() {
let file_path = file.path();
let file_name = file_path.to_string_lossy().to_string();
// Build the output path with transformations
let output_rel = transform_path(&file_name, &context.project_name);
let output_path = output_base.join(&output_rel);
// Create parent directories
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)
.context(format!("Failed to create directory: {}", parent.display()))?;
}
// Read file contents
let contents = file.contents();
// Check if this is a template file (.tmpl suffix)
if output_rel.ends_with(".tmpl") {
// Variable substitution
let text = std::str::from_utf8(contents)
.context("Template file must be UTF-8")?;
let processed = substitute_variables(text, context);
// Remove .tmpl extension
let final_path_str = output_rel.trim_end_matches(".tmpl");
let final_path = output_base.join(final_path_str);
if let Some(parent) = final_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&final_path, processed)?;
count += 1;
} else {
fs::write(&output_path, contents)?;
count += 1;
}
}
// Recurse into subdirectories
for dir in source.dirs() {
count += extract_dir(dir, output_base, relative_prefix, context)?;
}
Ok(count)
}
/// Transform template file paths:
/// - `_dot_` prefix -> `.` prefix (hidden files)
/// - `__name__` -> project name
fn transform_path(path: &str, project_name: &str) -> String {
let mut result = path.to_string();
// Replace __name__ with project name in all path components
result = result.replace("__name__", project_name);
// Handle _dot_ prefix for hidden files.
// Split into components and transform each.
let parts: Vec<&str> = result.split('/').collect();
let transformed: Vec<String> = parts
.iter()
.map(|part| {
if let Some(rest) = part.strip_prefix("_dot_") {
format!(".{}", rest)
} else {
part.to_string()
}
})
.collect();
transformed.join(std::path::MAIN_SEPARATOR_STR)
}
/// Simple variable substitution: replace {{VAR}} with values.
fn substitute_variables(text: &str, context: &TemplateContext) -> String {
text.replace("{{PROJECT_NAME}}", &context.project_name)
.replace("{{ANVIL_VERSION}}", &context.anvil_version)
.replace("{{ANVIL_VERSION_CURRENT}}", ANVIL_VERSION)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_transform_path_dot_prefix() {
assert_eq!(
transform_path("_dot_gitignore", "blink"),
".gitignore"
);
assert_eq!(
transform_path("_dot_vscode/settings.json", "blink"),
format!(".vscode{}settings.json", std::path::MAIN_SEPARATOR)
);
}
#[test]
fn test_transform_path_name_substitution() {
assert_eq!(
transform_path("__name__/__name__.ino.tmpl", "blink"),
format!("blink{}blink.ino.tmpl", std::path::MAIN_SEPARATOR)
);
}
#[test]
fn test_substitute_variables() {
let ctx = TemplateContext {
project_name: "my_project".to_string(),
anvil_version: "1.0.0".to_string(),
};
let input = "Name: {{PROJECT_NAME}}, Version: {{ANVIL_VERSION}}";
let output = substitute_variables(input, &ctx);
assert_eq!(output, "Name: my_project, Version: 1.0.0");
}
#[test]
fn test_template_exists() {
assert!(TemplateManager::template_exists("basic"));
assert!(!TemplateManager::template_exists("nonexistent"));
}
#[test]
fn test_extract_basic_template() {
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "test_proj".to_string(),
anvil_version: "1.0.0".to_string(),
};
let count = TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
assert!(count > 0, "Should extract at least one file");
// Verify key files exist
assert!(
tmp.path().join(".anvil.toml").exists(),
".anvil.toml should be created"
);
assert!(
tmp.path().join("test_proj").join("test_proj.ino").exists(),
"Sketch .ino should be created"
);
assert!(
tmp.path().join("lib").join("hal").join("hal.h").exists(),
"HAL header should be created"
);
assert!(
tmp.path().join(".gitignore").exists(),
".gitignore should be created"
);
}
#[test]
fn test_extract_template_variable_substitution() {
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "my_sensor".to_string(),
anvil_version: "1.0.0".to_string(),
};
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
// Read the generated .anvil.toml and check for project name
let config_content = fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap();
assert!(
config_content.contains("my_sensor"),
".anvil.toml should contain project name"
);
}
}