Layer 3: Templates as pure data, weather template, .anvilignore refresh system

Templates are now composed declaratively via template.toml -- no Rust code
changes needed to add new templates. The weather station is the first
composed template, demonstrating the full pattern.

Template engine:
- Composed templates declare base, required libraries, and per-board pins
- Overlay mechanism replaces base files (app, sketch, tests) cleanly
- Generic orchestration: extract base, apply overlay, install libs, assign pins
- Template name tracked in .anvil.toml for refresh awareness

Weather template (--template weather):
- WeatherApp with 2-second polling, C/F conversion, serial output
- TMP36 driver: TempSensor interface, Tmp36 impl, Tmp36Mock, Tmp36Sim
- Managed example tests in test_weather.cpp (unit + system)
- Minimal student starters in test_unit.cpp and test_system.cpp
- Per-board pin defaults (A0 for uno, A0 for mega, A0 for nano)

.anvilignore system:
- Glob pattern matching (*, ?) with comments and backslash normalization
- Default patterns protect student tests, app code, sketch, config
- anvil refresh --force respects .anvilignore
- anvil refresh --force --file <path> overrides ignore for one file
- anvil refresh --ignore/--unignore manages patterns from CLI
- Missing managed files always recreated even without --force
- .anvilignore itself is in NEVER_REFRESH (cannot be overwritten)

Refresh rewrite:
- Discovers all template-produced files dynamically (no hardcoded list)
- Extracts fresh template + libraries into temp dir for byte comparison
- Config template field drives which files are managed
- Separated missing-file creation from changed-file updates

428 tests passing on Windows MSVC, 0 warnings.
This commit is contained in:
Eric Ratliff
2026-02-21 20:52:48 -06:00
parent 0abe907811
commit ca855dd3af
17 changed files with 5190 additions and 236 deletions

View File

@@ -1,12 +1,17 @@
use include_dir::{include_dir, Dir};
use std::path::Path;
use std::fs;
use anyhow::{Result, bail, Context};
use std::collections::HashMap;
use anyhow::{Result, Context};
use serde::Deserialize;
use crate::version::ANVIL_VERSION;
// Embedded template directories
static BASIC_TEMPLATE: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/basic");
static WEATHER_TEMPLATE: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/weather");
/// Context variables available in .tmpl files via {{VAR}} substitution.
pub struct TemplateContext {
pub project_name: String,
pub anvil_version: String,
@@ -15,87 +20,282 @@ pub struct TemplateContext {
pub baud: u32,
}
/// Summary info for listing templates.
pub struct TemplateInfo {
pub name: String,
pub description: String,
pub is_default: bool,
pub libraries: Vec<String>,
pub board_capabilities: Vec<String>,
}
/// Metadata extracted from a composed template's template.toml.
/// Used by the `new` command to install libraries and assign pins.
pub struct ComposedMeta {
pub base: String,
pub libraries: Vec<String>,
pub board_capabilities: Vec<String>,
pub pins: HashMap<String, Vec<PinDefault>>,
}
/// A default pin assignment from a template.
pub struct PinDefault {
pub name: String,
pub pin: String,
pub mode: String,
}
impl ComposedMeta {
/// Get pin defaults for a given board, falling back to "default".
pub fn pins_for_board(&self, board: &str) -> Vec<&PinDefault> {
if let Some(pins) = self.pins.get(board) {
return pins.iter().collect();
}
if let Some(pins) = self.pins.get("default") {
return pins.iter().collect();
}
vec![]
}
}
// =========================================================================
// template.toml parsing (serde)
// =========================================================================
#[derive(Deserialize)]
struct TemplateToml {
template: TemplateHeader,
requires: Option<TemplateRequires>,
pins: Option<HashMap<String, HashMap<String, PinDef>>>,
}
#[derive(Deserialize)]
struct TemplateHeader {
#[allow(dead_code)]
name: String,
base: String,
description: String,
}
#[derive(Deserialize)]
struct TemplateRequires {
libraries: Option<Vec<String>>,
board_capabilities: Option<Vec<String>>,
}
#[derive(Deserialize)]
struct PinDef {
pin: String,
mode: String,
}
/// Parse template.toml from an embedded directory.
fn parse_template_toml(dir: &Dir<'_>) -> Option<TemplateToml> {
let file = dir.files().find(|f| {
f.path()
.file_name()
.map(|n| n == "template.toml")
.unwrap_or(false)
})?;
let content = std::str::from_utf8(file.contents()).ok()?;
toml::from_str(content).ok()
}
/// Look up the embedded Dir for a template name.
fn template_dir(name: &str) -> Option<&'static Dir<'static>> {
match name {
"basic" => Some(&BASIC_TEMPLATE),
"weather" => Some(&WEATHER_TEMPLATE),
_ => None,
}
}
// All composed templates (everything except "basic").
fn composed_template_entries() -> Vec<(&'static str, &'static Dir<'static>)> {
vec![("weather", &WEATHER_TEMPLATE)]
}
// =========================================================================
// TemplateManager -- public API
// =========================================================================
pub struct TemplateManager;
impl TemplateManager {
/// Check if a template name is known.
pub fn template_exists(name: &str) -> bool {
matches!(name, "basic")
template_dir(name).is_some()
}
/// List all available templates with metadata.
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,
},
]
let mut templates = vec![TemplateInfo {
name: "basic".to_string(),
description: "Arduino project with HAL abstraction, mocks, \
and test infrastructure"
.to_string(),
is_default: true,
libraries: vec![],
board_capabilities: vec![],
}];
// Composed templates: parse their template.toml
for (name, dir) in composed_template_entries() {
if let Some(toml) = parse_template_toml(dir) {
templates.push(TemplateInfo {
name: name.to_string(),
description: toml.template.description,
is_default: false,
libraries: toml
.requires
.as_ref()
.and_then(|r| r.libraries.clone())
.unwrap_or_default(),
board_capabilities: toml
.requires
.as_ref()
.and_then(|r| r.board_capabilities.clone())
.unwrap_or_default(),
});
}
}
templates
}
/// Extract a template into the output directory, applying variable
/// substitution and filename transformations.
/// Extract a template into a project directory.
///
/// For base templates (basic): extracts files directly.
/// For composed templates (weather): extracts base first, then overlays.
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 dir = template_dir(template_name)
.ok_or_else(|| anyhow::anyhow!("Unknown template: {}", template_name))?;
let count = extract_dir(template_dir, output_dir, "", context)?;
Ok(count)
// Check if this is a composed template
if let Some(toml) = parse_template_toml(dir) {
// Extract base first
let base_dir = template_dir(&toml.template.base).ok_or_else(|| {
anyhow::anyhow!(
"Template '{}' requires unknown base '{}'",
template_name,
toml.template.base
)
})?;
let mut count =
extract_dir_filtered(base_dir, output_dir, context, &[])?;
// Overlay template-specific files (skip template.toml)
count += extract_dir_filtered(
dir,
output_dir,
context,
&["template.toml"],
)?;
Ok(count)
} else {
// Base template -- extract directly
extract_dir_filtered(dir, output_dir, context, &[])
}
}
/// Get composed metadata for a template (libraries, pins, capabilities).
/// Returns None for base templates like "basic".
pub fn composed_meta(template_name: &str) -> Option<ComposedMeta> {
let dir = template_dir(template_name)?;
let toml = parse_template_toml(dir)?;
let libraries = toml
.requires
.as_ref()
.and_then(|r| r.libraries.clone())
.unwrap_or_default();
let board_capabilities = toml
.requires
.as_ref()
.and_then(|r| r.board_capabilities.clone())
.unwrap_or_default();
let mut pins: HashMap<String, Vec<PinDefault>> = HashMap::new();
if let Some(pin_map) = toml.pins {
for (board, assignments) in pin_map {
let mut defs: Vec<PinDefault> = assignments
.into_iter()
.map(|(name, def)| PinDefault {
name,
pin: def.pin,
mode: def.mode,
})
.collect();
// Sort for deterministic output
defs.sort_by(|a, b| a.name.cmp(&b.name));
pins.insert(board, defs);
}
}
Some(ComposedMeta {
base: toml.template.base,
libraries,
board_capabilities,
pins,
})
}
}
pub struct TemplateInfo {
pub name: String,
pub description: String,
pub is_default: bool,
}
// =========================================================================
// File extraction
// =========================================================================
/// Recursively extract a directory from the embedded template.
fn extract_dir(
/// Recursively extract files from an embedded directory, applying variable
/// substitution to .tmpl files and path transformations.
fn extract_dir_filtered(
source: &Dir<'_>,
output_base: &Path,
relative_prefix: &str,
context: &TemplateContext,
skip_filenames: &[&str],
) -> 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();
let file_name = file_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
// 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()))?;
// Skip files in the skip list
if skip_filenames.iter().any(|&s| s == file_name) {
continue;
}
let full_path = file_path.to_string_lossy().to_string();
let output_rel = transform_path(&full_path, &context.project_name);
let output_path = output_base.join(&output_rel);
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 {
@@ -104,9 +304,9 @@ fn extract_dir(
}
}
// Recurse into subdirectories
for dir in source.dirs() {
count += extract_dir(dir, output_base, relative_prefix, context)?;
count +=
extract_dir_filtered(dir, output_base, context, skip_filenames)?;
}
Ok(count)
@@ -117,12 +317,8 @@ fn extract_dir(
/// - `__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()
@@ -148,6 +344,10 @@ fn substitute_variables(text: &str, context: &TemplateContext) -> String {
.replace("{{BAUD}}", &context.baud.to_string())
}
// =========================================================================
// Tests
// =========================================================================
#[cfg(test)]
mod tests {
use super::*;
@@ -182,20 +382,53 @@ mod tests {
fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(),
baud: 9600,
};
let input = "Name: {{PROJECT_NAME}}, Board: {{BOARD_NAME}}, FQBN: {{FQBN}}, Baud: {{BAUD}}";
let input = "Name: {{PROJECT_NAME}}, Board: {{BOARD_NAME}}, \
FQBN: {{FQBN}}, Baud: {{BAUD}}";
let output = substitute_variables(input, &ctx);
assert_eq!(
output,
"Name: my_project, Board: mega, FQBN: arduino:avr:mega:cpu=atmega2560, Baud: 9600"
"Name: my_project, Board: mega, \
FQBN: arduino:avr:mega:cpu=atmega2560, Baud: 9600"
);
}
#[test]
fn test_template_exists() {
assert!(TemplateManager::template_exists("basic"));
assert!(TemplateManager::template_exists("weather"));
assert!(!TemplateManager::template_exists("nonexistent"));
}
#[test]
fn test_list_templates_includes_both() {
let templates = TemplateManager::list_templates();
assert!(templates.iter().any(|t| t.name == "basic"));
assert!(templates.iter().any(|t| t.name == "weather"));
}
#[test]
fn test_basic_is_default() {
let templates = TemplateManager::list_templates();
let basic = templates.iter().find(|t| t.name == "basic").unwrap();
assert!(basic.is_default);
}
#[test]
fn test_weather_requires_tmp36() {
let templates = TemplateManager::list_templates();
let weather = templates.iter().find(|t| t.name == "weather").unwrap();
assert!(weather.libraries.contains(&"tmp36".to_string()));
}
#[test]
fn test_weather_requires_analog() {
let templates = TemplateManager::list_templates();
let weather = templates.iter().find(|t| t.name == "weather").unwrap();
assert!(
weather.board_capabilities.contains(&"analog".to_string())
);
}
#[test]
fn test_extract_basic_template() {
let tmp = TempDir::new().unwrap();
@@ -207,26 +440,15 @@ mod tests {
baud: 115200,
};
let count = TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
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());
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"
tmp.path().join("test_proj").join("test_proj.ino").exists()
);
assert!(tmp.path().join("lib").join("hal").join("hal.h").exists());
assert!(tmp.path().join(".gitignore").exists());
}
#[test]
@@ -241,12 +463,105 @@ mod tests {
};
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
let config_content =
fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap();
assert!(config_content.contains("my_sensor"));
}
// Read the generated .anvil.toml and check for project name
let config_content = fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap();
#[test]
fn test_weather_composed_meta() {
let meta = TemplateManager::composed_meta("weather").unwrap();
assert_eq!(meta.base, "basic");
assert!(meta.libraries.contains(&"tmp36".to_string()));
assert!(!meta.pins.is_empty());
}
#[test]
fn test_weather_pins_for_board_uno() {
let meta = TemplateManager::composed_meta("weather").unwrap();
let pins = meta.pins_for_board("uno");
assert!(!pins.is_empty());
assert!(pins.iter().any(|p| p.name == "tmp36_data"));
}
#[test]
fn test_weather_pins_for_board_fallback() {
let meta = TemplateManager::composed_meta("weather").unwrap();
// Board "micro" is not in the template, falls back to "default"
let pins = meta.pins_for_board("micro");
assert!(!pins.is_empty());
}
#[test]
fn test_basic_has_no_composed_meta() {
assert!(TemplateManager::composed_meta("basic").is_none());
}
#[test]
fn test_extract_weather_overlays_app() {
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "wx".to_string(),
anvil_version: "1.0.0".to_string(),
board_name: "uno".to_string(),
fqbn: "arduino:avr:uno".to_string(),
baud: 115200,
};
TemplateManager::extract("weather", tmp.path(), &ctx).unwrap();
// Should have basic scaffold
assert!(tmp.path().join(".anvil.toml").exists());
assert!(tmp.path().join("build.sh").exists());
assert!(tmp.path().join("lib").join("hal").join("hal.h").exists());
// Should have weather-specific app
let app_path = tmp.path().join("lib").join("app").join("wx_app.h");
assert!(app_path.exists(), "Weather app.h should exist");
let app_content = fs::read_to_string(&app_path).unwrap();
assert!(
config_content.contains("my_sensor"),
".anvil.toml should contain project name"
app_content.contains("WeatherApp"),
"Should contain WeatherApp class"
);
assert!(
app_content.contains("TempSensor"),
"Should reference TempSensor"
);
// Should have weather-specific sketch
let ino_path = tmp.path().join("wx").join("wx.ino");
assert!(ino_path.exists());
let ino_content = fs::read_to_string(&ino_path).unwrap();
assert!(ino_content.contains("Tmp36Analog"));
assert!(ino_content.contains("WeatherApp"));
// Should have weather-specific tests
let unit_test = tmp.path().join("test").join("test_unit.cpp");
assert!(unit_test.exists());
let unit_content = fs::read_to_string(&unit_test).unwrap();
assert!(unit_content.contains("Tmp36Mock"));
let sys_test = tmp.path().join("test").join("test_system.cpp");
assert!(sys_test.exists());
let sys_content = fs::read_to_string(&sys_test).unwrap();
assert!(sys_content.contains("Tmp36Sim"));
}
#[test]
fn test_weather_no_template_toml_in_output() {
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "wx".to_string(),
anvil_version: "1.0.0".to_string(),
board_name: "uno".to_string(),
fqbn: "arduino:avr:uno".to_string(),
baud: 115200,
};
TemplateManager::extract("weather", tmp.path(), &ctx).unwrap();
assert!(
!tmp.path().join("template.toml").exists(),
"template.toml should NOT appear in output"
);
}
}