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:
234
src/templates/mod.rs
Normal file
234
src/templates/mod.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user