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

@@ -5,6 +5,41 @@ use std::path::PathBuf;
use crate::library;
use crate::project::config::ProjectConfig;
/// Install a library quietly -- extract files and update config only.
/// Used by the template orchestrator during `anvil new`.
/// Returns the list of written file paths relative to project root.
pub fn install_library(
name: &str,
project_dir: &std::path::Path,
) -> Result<Vec<String>> {
let meta = library::find_library(name).ok_or_else(|| {
anyhow::anyhow!("Unknown library: '{}'", name)
})?;
let project_root = ProjectConfig::find_project_root(project_dir)?;
let mut config = ProjectConfig::load(&project_root)?;
// Skip if already installed
if config.libraries.contains_key(name) {
return Ok(vec![]);
}
// Extract files
let written = library::extract_library(name, &project_root)?;
// Add to include_dirs if not present
let driver_include = format!("lib/drivers/{}", name);
if !config.build.include_dirs.contains(&driver_include) {
config.build.include_dirs.push(driver_include);
}
// Track in config
config.libraries.insert(name.to_string(), meta.version.clone());
config.save(&project_root)?;
Ok(written)
}
/// Add a library to the current project.
pub fn add_library(name: &str, pin: Option<&str>, project_dir: Option<&str>) -> Result<()> {
let meta = library::find_library(name)

View File

@@ -2,6 +2,7 @@ use anyhow::{Result, bail};
use colored::*;
use std::path::PathBuf;
use crate::board::pinmap;
use crate::board::presets::{self, BoardPreset};
use crate::templates::{TemplateManager, TemplateContext};
use crate::version::ANVIL_VERSION;
@@ -18,12 +19,43 @@ pub fn list_templates() -> Result<()> {
marker.bright_cyan()
);
println!(" {}", info.description);
if !info.libraries.is_empty() {
// Show what drivers are included and their wiring needs
let mut wiring_parts: Vec<String> = Vec::new();
for lib_name in &info.libraries {
if let Some(meta) = crate::library::find_library(lib_name) {
wiring_parts.push(format!(
"{} ({})",
meta.name, meta.wiring_summary()
));
}
}
println!(
" Includes: {}",
wiring_parts.join(", ").bright_yellow()
);
}
// Show the create command for non-default templates
if !info.is_default {
println!(
" Create: {}",
format!(
"anvil new <project-name> --template {}",
info.name
)
.bright_cyan()
);
}
println!();
}
println!("{}", "Usage:".bright_yellow().bold());
println!(" anvil new <project-name>");
println!(" anvil new <project-name> --template basic");
println!(
" anvil new <project-name> --template <name> --board <board>"
);
println!();
Ok(())
@@ -58,12 +90,10 @@ pub fn list_boards() -> Result<()> {
println!();
println!(
" {}",
"For boards not listed here, create a project and then:".bright_black()
);
println!(
" {}",
" anvil board --listall".bright_black()
"For boards not listed here, create a project and then:"
.bright_black()
);
println!(" {}", " anvil board --listall".bright_black());
println!();
Ok(())
@@ -90,7 +120,9 @@ pub fn create_project(
if !TemplateManager::template_exists(template_name) {
println!(
"{}",
format!("Template '{}' not found.", template_name).red().bold()
format!("Template '{}' not found.", template_name)
.red()
.bold()
);
println!();
list_templates()?;
@@ -99,8 +131,7 @@ pub fn create_project(
// Resolve board preset
let preset: &BoardPreset = match board {
Some(b) => {
match presets::find_preset(b) {
Some(b) => match presets::find_preset(b) {
Some(p) => p,
None => {
println!(
@@ -111,28 +142,36 @@ pub fn create_project(
list_boards()?;
bail!("Invalid board preset");
}
}
}
},
None => presets::find_preset(presets::DEFAULT_PRESET).unwrap(),
};
// Check board compatibility for composed templates (warn, never block)
if let Some(meta) = TemplateManager::composed_meta(template_name) {
check_board_compatibility(preset, &meta.board_capabilities);
}
println!(
"{}",
format!("Creating Arduino project: {}", name)
.bright_green()
.bold()
);
println!("{}", format!("Template: {}", template_name).bright_cyan());
println!(
"{}",
format!("Board: {} ({})", preset.name, preset.description).bright_cyan()
format!("Template: {}", template_name).bright_cyan()
);
println!(
"{}",
format!("Board: {} ({})", preset.name, preset.description)
.bright_cyan()
);
println!();
// Create project directory
std::fs::create_dir_all(&project_path)?;
// Extract template
// Extract template (basic scaffold + overlay for composed templates)
println!("{}", "Extracting template files...".bright_yellow());
let context = TemplateContext {
project_name: name.to_string(),
@@ -142,9 +181,85 @@ pub fn create_project(
baud: preset.baud,
};
let file_count = TemplateManager::extract(template_name, &project_path, &context)?;
let file_count =
TemplateManager::extract(template_name, &project_path, &context)?;
println!("{} Extracted {} files", "ok".green(), file_count);
// Record which template was used (for refresh to know what to manage)
{
let mut config =
crate::project::config::ProjectConfig::load(&project_path)?;
config.project.template = template_name.to_string();
config.save(&project_path)?;
}
// Generate .anvilignore with sensible defaults
crate::ignore::generate_default(&project_path, template_name)?;
// For composed templates: install libraries and assign pins
if let Some(meta) = TemplateManager::composed_meta(template_name) {
// Install required libraries
for lib_name in &meta.libraries {
println!();
println!(
"{}",
format!("Installing library: {}...", lib_name)
.bright_yellow()
);
let written =
crate::commands::lib::install_library(lib_name, &project_path)?;
for f in &written {
println!(" {} {}", "+".bright_green(), f.bright_white());
}
println!("{} {} installed", "ok".green(), lib_name);
}
// Assign default pins for this board
let pin_defaults = meta.pins_for_board(preset.name);
if !pin_defaults.is_empty() {
println!();
println!(
"{}",
"Assigning default pin mappings...".bright_yellow()
);
for pin_def in &pin_defaults {
match crate::commands::pin::install_pin_assignment(
&pin_def.name,
&pin_def.pin,
&pin_def.mode,
preset.name,
&project_path,
) {
Ok(()) => {
println!(
" {} {} -> {} [{}]",
"ok".green(),
pin_def.name.bold(),
pin_def.pin,
pin_def.mode
);
}
Err(e) => {
println!(
" {} {}: {}",
"!!".red(),
pin_def.name,
e
);
println!(
" Assign manually: {}",
format!(
"anvil pin --assign {} {} --mode {}",
pin_def.name, pin_def.pin, pin_def.mode
)
.bright_cyan()
);
}
}
}
}
}
// Make shell scripts executable on Unix
#[cfg(unix)]
{
@@ -174,11 +289,52 @@ pub fn create_project(
);
println!();
print_next_steps(name);
print_next_steps(name, template_name);
Ok(())
}
/// Check if a board supports the required capabilities and warn if not.
fn check_board_compatibility(
preset: &BoardPreset,
required: &[String],
) {
if required.is_empty() {
return;
}
let pm = pinmap::find_pinmap(preset.name);
if pm.is_none() {
// No pinmap data for this board -- can't check
return;
}
let pm = pm.unwrap();
let caps = pinmap::board_capabilities(pm);
for req in required {
if !caps.contains(&req.as_str()) {
println!(
"{}",
format!(
"note: This template needs '{}' pins, which {} \
may not support.",
req, preset.name
)
.bright_yellow()
);
println!(
" {}",
"Your tests will still work (mocks don't need hardware)."
.bright_black()
);
println!(
" {}",
"You may need to adjust pin assignments for your board."
.bright_black()
);
println!();
}
}
}
fn validate_project_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("Project name cannot be empty");
@@ -266,13 +422,47 @@ fn make_executable(project_dir: &PathBuf) {
}
}
fn print_next_steps(project_name: &str) {
fn print_next_steps(project_name: &str, template_name: &str) {
println!("{}", "Next steps:".bright_yellow().bold());
println!(
" 1. {}",
format!("cd {}", project_name).bright_cyan()
);
// For composed templates, wiring is step 2
if template_name != "basic" {
println!(
" 2. Review wiring: {}",
"anvil pin --audit".bright_cyan()
);
if cfg!(target_os = "windows") {
println!(
" 3. Run host tests: {}",
"test\\run_tests.bat --clean".bright_cyan()
);
println!(
" 4. Compile: {}",
"build.bat".bright_cyan()
);
println!(
" 5. Upload: {}",
"upload.bat --monitor".bright_cyan()
);
} else {
println!(
" 3. Run host tests: {}",
"./test/run_tests.sh --clean".bright_cyan()
);
println!(
" 4. Compile: {}",
"./build.sh".bright_cyan()
);
println!(
" 5. Upload: {}",
"./upload.sh --monitor".bright_cyan()
);
}
} else {
if cfg!(target_os = "windows") {
println!(
" 2. Compile: {}",
@@ -324,10 +514,12 @@ fn print_next_steps(project_name: &str) {
println!();
println!(
" {}",
"On Windows: build.bat, upload.bat, monitor.bat, test\\run_tests.bat"
"On Windows: build.bat, upload.bat, monitor.bat, \
test\\run_tests.bat"
.bright_black()
);
}
}
println!(
" {}",

View File

@@ -237,6 +237,43 @@ pub fn assign_pin(
Ok(())
}
/// Assign a pin quietly -- validate, write config, no printing.
/// Used by the template orchestrator during `anvil new`.
pub fn install_pin_assignment(
name: &str,
pin_str: &str,
mode: &str,
board_name: &str,
project_dir: &std::path::Path,
) -> Result<()> {
let project_root = ProjectConfig::find_project_root(project_dir)?;
let pinmap = require_pinmap(board_name)?;
let pin_num = pinmap::resolve_alias(pinmap, pin_str).ok_or_else(|| {
anyhow::anyhow!(
"Pin '{}' not found on board '{}'",
pin_str,
board_name
)
})?;
if !ALL_MODES.contains(&mode) {
bail!("Unknown pin mode: '{}'", mode);
}
let pin_info = pinmap::get_pin(pinmap, pin_num).unwrap();
validate_mode_for_pin(pin_info, mode)?;
validate_pin_name(name)?;
let assignment = PinAssignment {
pin: pin_num,
mode: mode.to_string(),
};
write_pin_assignment(&project_root, board_name, name, &assignment)?;
Ok(())
}
// =========================================================================
// Assign a bus group
// =========================================================================

View File

@@ -1,39 +1,46 @@
use anyhow::{Result, Context};
use colored::*;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::fs;
use crate::ignore::{self, AnvilIgnore};
use crate::project::config::ProjectConfig;
use crate::templates::{TemplateManager, TemplateContext};
use crate::version::ANVIL_VERSION;
/// Files that anvil owns and can safely refresh.
/// These are build/deploy infrastructure -- not user source code.
const REFRESHABLE_FILES: &[&str] = &[
"build.sh",
"build.bat",
"upload.sh",
"upload.bat",
"monitor.sh",
"monitor.bat",
"test.sh",
"test.bat",
"_detect_port.ps1",
"_monitor_filter.ps1",
"test/run_tests.sh",
"test/run_tests.bat",
"test/CMakeLists.txt",
"test/mocks/mock_arduino.h",
"test/mocks/mock_arduino.cpp",
"test/mocks/Arduino.h",
"test/mocks/Wire.h",
"test/mocks/SPI.h",
"test/mocks/mock_hal.h",
"test/mocks/sim_hal.h",
/// Files that are never refreshable, even if they appear in the template.
/// .anvilignore itself is the user's protection config -- never overwrite it.
const NEVER_REFRESH: &[&str] = &[
".anvilignore",
];
pub fn run_refresh(project_dir: Option<&str>, force: bool) -> Result<()> {
// Resolve project directory
/// Recursively walk a directory and return all file paths relative to `base`.
/// Uses forward-slash separators regardless of platform.
fn discover_files(base: &Path, dir: &Path) -> Vec<String> {
let mut files = Vec::new();
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return files,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
files.extend(discover_files(base, &path));
} else if let Ok(rel) = path.strip_prefix(base) {
let rel_str = rel.to_string_lossy().replace('\\', "/");
files.push(rel_str);
}
}
files.sort();
files
}
/// Main refresh entry point.
pub fn run_refresh(
project_dir: Option<&str>,
force: bool,
file_override: Option<&str>,
) -> Result<()> {
let project_path = match project_dir {
Some(dir) => PathBuf::from(dir),
None => std::env::current_dir()
@@ -42,61 +49,101 @@ pub fn run_refresh(project_dir: Option<&str>, force: bool) -> Result<()> {
let project_root = ProjectConfig::find_project_root(&project_path)?;
let config = ProjectConfig::load(&project_root)?;
let anvilignore = AnvilIgnore::load(&project_root)?;
let template_name = &config.project.template;
println!(
"Refreshing project: {}",
config.project.name.bright_white().bold()
);
println!(
"Template: {}",
template_name.bright_cyan()
);
println!(
"Project directory: {}",
project_root.display().to_string().bright_black()
);
println!();
// Generate fresh copies of all refreshable files from the template
let template_name = "basic";
// Build template context for extraction
let context = TemplateContext {
project_name: config.project.name.clone(),
anvil_version: ANVIL_VERSION.to_string(),
board_name: config.build.default.clone(),
fqbn: config.default_fqbn().unwrap_or_else(|_| "arduino:avr:uno".to_string()),
fqbn: config
.default_fqbn()
.unwrap_or_else(|_| "arduino:avr:uno".to_string()),
baud: config.monitor.baud,
};
// Extract template into a temp directory so we can compare
let temp_dir = tempfile::tempdir()
.context("Failed to create temp directory")?;
// Extract fresh template + libraries into temp directory
let temp_dir =
tempfile::tempdir().context("Failed to create temp directory")?;
TemplateManager::extract(template_name, temp_dir.path(), &context)?;
// Compare each refreshable file
let mut up_to_date = Vec::new();
let mut will_create = Vec::new();
let mut has_changes = Vec::new();
// Also extract library files into temp (for composed templates)
for lib_name in config.libraries.keys() {
let _ = crate::library::extract_library(lib_name, temp_dir.path());
}
// Discover ALL files the template produces
let all_template_files = discover_files(temp_dir.path(), temp_dir.path());
// Normalize the --file override
let file_override_norm = file_override.map(|f| f.replace('\\', "/"));
// Compare each template-produced file
let mut up_to_date = Vec::new();
let mut will_create: Vec<String> = Vec::new();
let mut has_changes: Vec<String> = Vec::new();
let mut ignored: Vec<(String, String)> = Vec::new(); // (file, pattern)
for filename in &all_template_files {
// Skip files that are never refreshable
if NEVER_REFRESH.iter().any(|&nr| nr == filename) {
continue;
}
for &filename in REFRESHABLE_FILES {
let existing = project_root.join(filename);
let fresh = temp_dir.path().join(filename);
if !fresh.exists() {
// Template doesn't produce this file (shouldn't happen)
continue;
}
// Check .anvilignore (unless this file is the --file override)
let is_override = file_override_norm
.as_ref()
.map(|f| f == filename)
.unwrap_or(false);
if !is_override {
if let Some(pattern) = anvilignore.matching_pattern(filename) {
ignored.push((
filename.clone(),
pattern.to_string(),
));
continue;
}
}
let fresh_content = fs::read(&fresh)
.context(format!("Failed to read template file: {}", filename))?;
.context(format!("Failed to read template: {}", filename))?;
if !existing.exists() {
will_create.push(filename);
will_create.push(filename.clone());
continue;
}
let existing_content = fs::read(&existing)
.context(format!("Failed to read project file: {}", filename))?;
.context(format!("Failed to read project: {}", filename))?;
if existing_content == fresh_content {
up_to_date.push(filename);
up_to_date.push(filename.clone());
} else {
has_changes.push(filename);
has_changes.push(filename.clone());
}
}
@@ -111,7 +158,11 @@ pub fn run_refresh(project_dir: Option<&str>, force: bool) -> Result<()> {
if !will_create.is_empty() {
for f in &will_create {
println!(" {} {} (new)", "+".bright_green(), f.bright_white());
println!(
" {} {} (new)",
"+".bright_green(),
f.bright_white()
);
}
}
@@ -125,64 +176,68 @@ pub fn run_refresh(project_dir: Option<&str>, force: bool) -> Result<()> {
}
}
// Report ignored files -- always, so the user knows
if !ignored.is_empty() {
println!();
println!(
"{} {} file(s) protected by .anvilignore:",
"skip".bright_black(),
ignored.len()
);
for (file, pattern) in &ignored {
if file == pattern {
println!(
" {} {} (ignored)",
"-".bright_black(),
file.bright_black()
);
} else {
println!(
" {} {} (matches: {})",
"-".bright_black(),
file.bright_black(),
pattern.bright_black()
);
}
}
if file_override.is_none() {
println!(
" {}",
"Override one: anvil refresh --force --file <path>"
.bright_black()
);
}
}
// Decide what to do
if has_changes.is_empty() && will_create.is_empty() {
println!();
println!(
"{}",
"All scripts are up to date. Nothing to do."
"All managed files are up to date. Nothing to do."
.bright_green()
.bold()
);
return Ok(());
}
if !has_changes.is_empty() && !force {
println!();
println!(
"{} {} script(s) differ from the latest Anvil templates.",
"!".bright_yellow(),
has_changes.len()
);
println!(
"This is normal after upgrading Anvil. To update them, run:"
);
println!();
println!(" {}", "anvil refresh --force".bright_cyan());
println!();
println!(
" {}",
"Only build scripts are replaced. Your .anvil.toml and source code are never touched."
.bright_black()
);
return Ok(());
}
// Apply updates
let files_to_write: Vec<&str> = if force {
will_create.iter().chain(has_changes.iter()).copied().collect()
} else {
will_create.to_vec()
};
for filename in &files_to_write {
// Always create missing files (they are not conflicting, just absent)
if !will_create.is_empty() {
for filename in &will_create {
let fresh = temp_dir.path().join(filename);
let dest = project_root.join(filename);
// Ensure parent directory exists
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&fresh, &dest)
.context(format!("Failed to write: {}", filename))?;
}
// Make shell scripts executable on Unix
// Make new shell scripts executable on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for filename in &files_to_write {
for filename in &will_create {
if filename.ends_with(".sh") {
let path = project_root.join(filename);
if let Ok(meta) = fs::metadata(&path) {
@@ -194,12 +249,142 @@ pub fn run_refresh(project_dir: Option<&str>, force: bool) -> Result<()> {
}
}
println!(
"{} Created {} missing file(s).",
"ok".green(),
will_create.len()
);
}
if !has_changes.is_empty() && !force {
println!();
println!(
"{} {} file(s) differ from the latest Anvil templates.",
"!".bright_yellow(),
has_changes.len()
);
println!(
"This is normal after upgrading Anvil. To update them, run:"
);
println!();
println!(" {}", "anvil refresh --force".bright_cyan());
println!();
println!(
" {}",
"Only managed files are replaced. Your source code \
and .anvilignore files are never touched."
.bright_black()
);
return Ok(());
}
// Apply changed file updates (requires --force)
if !has_changes.is_empty() {
for filename in &has_changes {
let fresh = temp_dir.path().join(filename);
let dest = project_root.join(filename);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&fresh, &dest)
.context(format!("Failed to write: {}", filename))?;
}
// Make shell scripts executable on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for filename in &has_changes {
if filename.ends_with(".sh") {
let path = project_root.join(filename);
if let Ok(meta) = fs::metadata(&path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = fs::set_permissions(&path, perms);
}
}
}
}
println!(
"{} Updated {} file(s).",
"ok".green(),
files_to_write.len()
has_changes.len()
);
}
Ok(())
}
/// Add a pattern to .anvilignore.
pub fn add_ignore(project_dir: Option<&str>, pattern: &str) -> Result<()> {
let project_path = match project_dir {
Some(dir) => PathBuf::from(dir),
None => std::env::current_dir()?,
};
let project_root = ProjectConfig::find_project_root(&project_path)?;
// Check if already present
let anvilignore = AnvilIgnore::load(&project_root)?;
let normalized = pattern.replace('\\', "/");
if anvilignore.has_pattern(&normalized) {
println!(
"{} Pattern already in .anvilignore: {}",
"ok".green(),
normalized.bright_white()
);
return Ok(());
}
ignore::add_pattern(&project_root, pattern)?;
println!(
"{} Added to .anvilignore: {}",
"ok".green(),
normalized.bright_white().bold()
);
Ok(())
}
/// Remove a pattern from .anvilignore.
pub fn remove_ignore(
project_dir: Option<&str>,
pattern: &str,
) -> Result<()> {
let project_path = match project_dir {
Some(dir) => PathBuf::from(dir),
None => std::env::current_dir()?,
};
let project_root = ProjectConfig::find_project_root(&project_path)?;
let removed = ignore::remove_pattern(&project_root, pattern)?;
let normalized = pattern.replace('\\', "/");
if removed {
println!(
"{} Removed from .anvilignore: {}",
"ok".green(),
normalized.bright_white().bold()
);
println!(
" {}",
"This file will now be updated by anvil refresh --force."
.bright_black()
);
} else {
println!(
"{} Pattern not found in .anvilignore: {}",
"!".bright_yellow(),
normalized.bright_white()
);
// Show current patterns
let anvilignore = AnvilIgnore::load(&project_root)?;
if !anvilignore.patterns().is_empty() {
println!(" Current patterns:");
for p in anvilignore.patterns() {
println!(" {}", p.bright_black());
}
}
}
Ok(())
}

472
src/ignore.rs Normal file
View File

@@ -0,0 +1,472 @@
use std::fs;
use std::path::Path;
use anyhow::{Result, Context};
const IGNORE_FILENAME: &str = ".anvilignore";
/// A parsed .anvilignore file.
#[derive(Debug, Clone)]
pub struct AnvilIgnore {
/// Raw patterns from the file (comments and blanks stripped).
patterns: Vec<String>,
}
impl AnvilIgnore {
/// Load .anvilignore from a project directory.
/// Returns an empty ignore set if the file does not exist.
pub fn load(project_dir: &Path) -> Result<Self> {
let path = project_dir.join(IGNORE_FILENAME);
if !path.exists() {
return Ok(Self { patterns: vec![] });
}
let content = fs::read_to_string(&path)
.context("Failed to read .anvilignore")?;
Ok(Self::parse(&content))
}
/// Parse ignore patterns from text content.
pub fn parse(content: &str) -> Self {
let patterns = content
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|line| normalize_path(line))
.collect();
Self { patterns }
}
/// Check if a file path matches any ignore pattern.
/// Paths should use forward slashes (normalized).
pub fn is_ignored(&self, path: &str) -> bool {
let normalized = normalize_path(path);
self.patterns.iter().any(|pat| glob_match(pat, &normalized))
}
/// Return which pattern caused a path to be ignored (for UX reporting).
pub fn matching_pattern(&self, path: &str) -> Option<&str> {
let normalized = normalize_path(path);
self.patterns
.iter()
.find(|pat| glob_match(pat, &normalized))
.map(|s| s.as_str())
}
/// All patterns in the file.
pub fn patterns(&self) -> &[String] {
&self.patterns
}
/// Check if a pattern already exists.
pub fn has_pattern(&self, pattern: &str) -> bool {
let normalized = normalize_path(pattern);
self.patterns.iter().any(|p| *p == normalized)
}
}
/// Add a pattern to the .anvilignore file. Creates the file if needed.
pub fn add_pattern(project_dir: &Path, pattern: &str) -> Result<()> {
let path = project_dir.join(IGNORE_FILENAME);
let normalized = normalize_path(pattern);
// Load existing content or start fresh
let mut content = if path.exists() {
fs::read_to_string(&path)
.context("Failed to read .anvilignore")?
} else {
default_header()
};
// Check for duplicate
let ignore = AnvilIgnore::parse(&content);
if ignore.has_pattern(&normalized) {
return Ok(());
}
// Append
if !content.ends_with('\n') {
content.push('\n');
}
content.push_str(&normalized);
content.push('\n');
fs::write(&path, content)
.context("Failed to write .anvilignore")?;
Ok(())
}
/// Remove a pattern from the .anvilignore file.
/// Returns true if the pattern was found and removed.
pub fn remove_pattern(project_dir: &Path, pattern: &str) -> Result<bool> {
let path = project_dir.join(IGNORE_FILENAME);
if !path.exists() {
return Ok(false);
}
let content = fs::read_to_string(&path)
.context("Failed to read .anvilignore")?;
let normalized = normalize_path(pattern);
let mut found = false;
let mut lines: Vec<&str> = content.lines().collect();
lines.retain(|line| {
let trimmed = line.trim();
if normalize_path(trimmed) == normalized {
found = true;
false
} else {
true
}
});
if found {
let mut output = lines.join("\n");
if !output.ends_with('\n') {
output.push('\n');
}
fs::write(&path, output)
.context("Failed to write .anvilignore")?;
}
Ok(found)
}
/// Generate a default .anvilignore for a new project.
pub fn generate_default(project_dir: &Path, _template_name: &str) -> Result<()> {
let path = project_dir.join(IGNORE_FILENAME);
if path.exists() {
return Ok(());
}
let mut content = default_header();
// Student-owned test files are always protected
content.push_str("# Your test files -- anvil refresh will never touch these.\n");
content.push_str("test/test_unit.cpp\n");
content.push_str("test/test_system.cpp\n");
content.push_str("\n");
// Source code is always protected
content.push_str("# Your application code.\n");
content.push_str("lib/app/*\n");
content.push_str("\n");
// Sketch is protected
content.push_str("# Your Arduino sketch.\n");
// Pattern: any-directory/*.ino (do NOT use format! here)
content.push_str("*/*.ino\n");
content.push_str("\n");
// Config and project files are protected
content.push_str("# Project configuration and docs.\n");
content.push_str(".anvil.toml\n");
content.push_str(".gitignore\n");
content.push_str(".editorconfig\n");
content.push_str(".clang-format\n");
content.push_str(".vscode/*\n");
content.push_str("README.md\n");
fs::write(&path, content)
.context("Failed to create .anvilignore")?;
Ok(())
}
fn default_header() -> String {
"# .anvilignore -- files that anvil refresh will never overwrite.\n\
# Patterns support * (any chars) and ? (single char) wildcards.\n\
# One pattern per line. Lines starting with # are comments.\n\
#\n\
# Manage with:\n\
# anvil refresh --ignore \"test/my_*.cpp\"\n\
# anvil refresh --unignore \"test/my_*.cpp\"\n\
#\n\n"
.to_string()
}
/// Normalize path separators to forward slashes for consistent matching.
fn normalize_path(path: &str) -> String {
path.replace('\\', "/")
}
/// Simple glob matching with * (any sequence) and ? (single char).
/// Both pattern and text should be normalized (forward slashes).
pub fn glob_match(pattern: &str, text: &str) -> bool {
glob_match_inner(pattern.as_bytes(), text.as_bytes())
}
fn glob_match_inner(pattern: &[u8], text: &[u8]) -> bool {
let mut pi = 0;
let mut ti = 0;
let mut star_pi = usize::MAX; // pattern index after last *
let mut star_ti = 0; // text index when last * was hit
while ti < text.len() {
if pi < pattern.len() && (pattern[pi] == b'?' || pattern[pi] == text[ti]) {
pi += 1;
ti += 1;
} else if pi < pattern.len() && pattern[pi] == b'*' {
star_pi = pi + 1;
star_ti = ti;
pi += 1;
} else if star_pi != usize::MAX {
// Backtrack: let * consume one more character
pi = star_pi;
star_ti += 1;
ti = star_ti;
} else {
return false;
}
}
// Consume trailing *s in pattern
while pi < pattern.len() && pattern[pi] == b'*' {
pi += 1;
}
pi == pattern.len()
}
// =========================================================================
// Tests
// =========================================================================
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
// -- Glob matching ----------------------------------------------------
#[test]
fn test_glob_exact_match() {
assert!(glob_match("test/test_unit.cpp", "test/test_unit.cpp"));
}
#[test]
fn test_glob_no_match() {
assert!(!glob_match("test/test_unit.cpp", "test/test_system.cpp"));
}
#[test]
fn test_glob_star_suffix() {
assert!(glob_match("test/test_*", "test/test_unit.cpp"));
assert!(glob_match("test/test_*", "test/test_system.cpp"));
assert!(!glob_match("test/test_*", "test/run_tests.sh"));
}
#[test]
fn test_glob_star_prefix() {
assert!(glob_match("*.cpp", "test_unit.cpp"));
assert!(!glob_match("*.cpp", "test_unit.h"));
}
#[test]
fn test_glob_star_middle() {
assert!(glob_match("test/test_*.cpp", "test/test_unit.cpp"));
assert!(glob_match("test/test_*.cpp", "test/test_weather.cpp"));
assert!(!glob_match("test/test_*.cpp", "test/run_tests.sh"));
}
#[test]
fn test_glob_question_mark() {
assert!(glob_match("test?.cpp", "test1.cpp"));
assert!(glob_match("test?.cpp", "testA.cpp"));
assert!(!glob_match("test?.cpp", "test12.cpp"));
}
#[test]
fn test_glob_double_star() {
// * matches anything including path separators in our simple model
assert!(glob_match("lib/*", "lib/app/my_app.h"));
}
#[test]
fn test_glob_star_matches_empty() {
assert!(glob_match("test/*", "test/"));
}
#[test]
fn test_glob_multiple_stars() {
assert!(glob_match("*/test_*.cpp", "test/test_unit.cpp"));
}
#[test]
fn test_glob_backslash_normalized() {
// normalize_path converts \ to /
let norm = normalize_path("test\\test_unit.cpp");
assert!(glob_match("test/test_unit.cpp", &norm));
}
// -- AnvilIgnore parsing ----------------------------------------------
#[test]
fn test_parse_empty() {
let ignore = AnvilIgnore::parse("");
assert!(ignore.patterns().is_empty());
}
#[test]
fn test_parse_comments_and_blanks() {
let content = "# comment\n\n # another\n\ntest/test_unit.cpp\n";
let ignore = AnvilIgnore::parse(content);
assert_eq!(ignore.patterns().len(), 1);
assert_eq!(ignore.patterns()[0], "test/test_unit.cpp");
}
#[test]
fn test_parse_trims_whitespace() {
let content = " test/test_unit.cpp \n";
let ignore = AnvilIgnore::parse(content);
assert_eq!(ignore.patterns()[0], "test/test_unit.cpp");
}
#[test]
fn test_is_ignored_exact() {
let ignore = AnvilIgnore::parse("test/test_unit.cpp\n");
assert!(ignore.is_ignored("test/test_unit.cpp"));
assert!(!ignore.is_ignored("test/test_system.cpp"));
}
#[test]
fn test_is_ignored_glob() {
let ignore = AnvilIgnore::parse("test/test_*.cpp\n");
assert!(ignore.is_ignored("test/test_unit.cpp"));
assert!(ignore.is_ignored("test/test_system.cpp"));
assert!(!ignore.is_ignored("test/run_tests.sh"));
}
#[test]
fn test_is_ignored_normalizes_backslash() {
let ignore = AnvilIgnore::parse("test/test_unit.cpp\n");
assert!(ignore.is_ignored("test\\test_unit.cpp"));
}
#[test]
fn test_matching_pattern_returns_which() {
let ignore = AnvilIgnore::parse(
"test/test_unit.cpp\nlib/app/*\n",
);
assert_eq!(
ignore.matching_pattern("test/test_unit.cpp"),
Some("test/test_unit.cpp")
);
assert_eq!(
ignore.matching_pattern("lib/app/my_app.h"),
Some("lib/app/*")
);
assert_eq!(ignore.matching_pattern("build.sh"), None);
}
#[test]
fn test_has_pattern() {
let ignore = AnvilIgnore::parse("test/test_unit.cpp\n");
assert!(ignore.has_pattern("test/test_unit.cpp"));
assert!(!ignore.has_pattern("test/test_system.cpp"));
}
// -- File operations --------------------------------------------------
#[test]
fn test_load_missing_file_returns_empty() {
let tmp = TempDir::new().unwrap();
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(ignore.patterns().is_empty());
}
#[test]
fn test_load_existing_file() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join(".anvilignore"),
"test/test_unit.cpp\n",
)
.unwrap();
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert_eq!(ignore.patterns().len(), 1);
}
#[test]
fn test_add_pattern_creates_file() {
let tmp = TempDir::new().unwrap();
add_pattern(tmp.path(), "test/test_unit.cpp").unwrap();
let content =
fs::read_to_string(tmp.path().join(".anvilignore")).unwrap();
assert!(content.contains("test/test_unit.cpp"));
}
#[test]
fn test_add_pattern_no_duplicate() {
let tmp = TempDir::new().unwrap();
add_pattern(tmp.path(), "test/test_unit.cpp").unwrap();
add_pattern(tmp.path(), "test/test_unit.cpp").unwrap();
let content =
fs::read_to_string(tmp.path().join(".anvilignore")).unwrap();
let count = content.matches("test/test_unit.cpp").count();
assert_eq!(count, 1);
}
#[test]
fn test_add_pattern_normalizes_backslash() {
let tmp = TempDir::new().unwrap();
add_pattern(tmp.path(), "test\\test_unit.cpp").unwrap();
let content =
fs::read_to_string(tmp.path().join(".anvilignore")).unwrap();
assert!(content.contains("test/test_unit.cpp"));
}
#[test]
fn test_remove_pattern() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join(".anvilignore"),
"test/test_unit.cpp\ntest/test_system.cpp\n",
)
.unwrap();
let removed =
remove_pattern(tmp.path(), "test/test_unit.cpp").unwrap();
assert!(removed);
let content =
fs::read_to_string(tmp.path().join(".anvilignore")).unwrap();
assert!(!content.contains("test/test_unit.cpp"));
assert!(content.contains("test/test_system.cpp"));
}
#[test]
fn test_remove_pattern_not_found() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join(".anvilignore"),
"test/test_unit.cpp\n",
)
.unwrap();
let removed =
remove_pattern(tmp.path(), "build.sh").unwrap();
assert!(!removed);
}
#[test]
fn test_generate_default_creates_file() {
let tmp = TempDir::new().unwrap();
generate_default(tmp.path(), "weather").unwrap();
let content =
fs::read_to_string(tmp.path().join(".anvilignore")).unwrap();
assert!(content.contains("test/test_unit.cpp"));
assert!(content.contains("test/test_system.cpp"));
assert!(content.contains("lib/app/*"));
assert!(content.contains(".anvil.toml"));
}
#[test]
fn test_generate_default_does_not_overwrite() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join(".anvilignore"),
"my_custom_pattern\n",
)
.unwrap();
generate_default(tmp.path(), "basic").unwrap();
let content =
fs::read_to_string(tmp.path().join(".anvilignore")).unwrap();
assert!(content.contains("my_custom_pattern"));
assert!(!content.contains("test/test_unit.cpp"));
}
}

View File

@@ -4,3 +4,4 @@ pub mod project;
pub mod board;
pub mod templates;
pub mod library;
pub mod ignore;

View File

@@ -81,9 +81,21 @@ enum Commands {
/// Path to project directory (defaults to current directory)
dir: Option<String>,
/// Overwrite scripts even if they have been modified
/// Overwrite managed files even if they have been modified
#[arg(long)]
force: bool,
/// Override .anvilignore for a specific file (use with --force)
#[arg(long, value_name = "PATH")]
file: Option<String>,
/// Add a pattern to .anvilignore (protect a file from refresh)
#[arg(long, value_name = "PATTERN", conflicts_with_all = ["unignore", "force"])]
ignore: Option<String>,
/// Remove a pattern from .anvilignore (allow refresh to update it)
#[arg(long, value_name = "PATTERN", conflicts_with_all = ["ignore", "force"])]
unignore: Option<String>,
},
/// Manage board profiles in .anvil.toml
@@ -264,12 +276,25 @@ fn main() -> Result<()> {
commands::devices::scan_devices()
}
}
Commands::Refresh { dir, force } => {
Commands::Refresh { dir, force, file, ignore, unignore } => {
if let Some(pattern) = ignore {
commands::refresh::add_ignore(
dir.as_deref(),
&pattern,
)
} else if let Some(pattern) = unignore {
commands::refresh::remove_ignore(
dir.as_deref(),
&pattern,
)
} else {
commands::refresh::run_refresh(
dir.as_deref(),
force,
file.as_deref(),
)
}
}
Commands::Board { name, add, remove, default, listall, id, baud, dir } => {
if listall {
commands::board::listall_boards(name.as_deref())

View File

@@ -25,6 +25,12 @@ pub struct ProjectConfig {
pub struct ProjectMeta {
pub name: String,
pub anvil_version: String,
#[serde(default = "default_template")]
pub template: String,
}
fn default_template() -> String {
"basic".to_string()
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -93,6 +99,7 @@ impl ProjectConfig {
project: ProjectMeta {
name: name.to_string(),
anvil_version: ANVIL_VERSION.to_string(),
template: "basic".to_string(),
},
build: BuildConfig {
default: "uno".to_string(),

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 {
let mut templates = vec![TemplateInfo {
name: "basic".to_string(),
description: "Arduino project with HAL abstraction, mocks, and test infrastructure".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(),
});
}
}
/// Extract a template into the output directory, applying variable
/// substitution and filename transformations.
templates
}
/// 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))?;
// 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"],
)?;
let count = extract_dir(template_dir, output_dir, "", context)?;
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"
);
}
}

View File

@@ -0,0 +1,35 @@
/*
* {{PROJECT_NAME}}.ino -- TMP36 weather station
*
* Reads temperature from a TMP36 sensor every 2 seconds
* and prints to Serial:
*
* Temperature: 23.5 C (74.3 F)
*
* All logic lives in lib/app/{{PROJECT_NAME}}_app.h which depends
* on the HAL and TempSensor interfaces, making it fully testable
* on the host without hardware.
*
* Wiring (TMP36, flat side facing you):
* Pin 1 (left) -> 5V
* Pin 2 (middle) -> A0 (analog input)
* Pin 3 (right) -> GND
*
* Serial: 115200 baud
*/
#include <hal_arduino.h>
#include <{{PROJECT_NAME}}_app.h>
#include <tmp36_analog.h>
static ArduinoHal hw;
static Tmp36Analog sensor(&hw, A0);
static WeatherApp app(&hw, &sensor);
void setup() {
app.begin();
}
void loop() {
app.update();
}

View File

@@ -0,0 +1,100 @@
#ifndef APP_H
#define APP_H
#include <hal.h>
#include "tmp36.h"
#include <stdio.h>
/*
* WeatherApp -- Reads a TMP36 temperature sensor and reports to Serial.
*
* Every READ_INTERVAL_MS (2 seconds), reads the temperature and prints
* a formatted line like:
*
* Temperature: 23.5 C (74.3 F)
*
* The sensor is injected through the TempSensor interface, so this
* class works with real hardware, mocks, or simulations.
*
* Wiring (TMP36):
* Pin 1 (left, flat side facing you) -> 5V
* Pin 2 (middle) -> A0 (analog input)
* Pin 3 (right) -> GND
*/
class WeatherApp {
public:
static constexpr unsigned long READ_INTERVAL_MS = 2000;
WeatherApp(Hal* hal, TempSensor* sensor)
: hal_(hal)
, sensor_(sensor)
, last_read_ms_(0)
, last_celsius_(0.0f)
, last_fahrenheit_(0.0f)
, read_count_(0)
{}
// Call once from setup()
void begin() {
hal_->serialBegin(115200);
hal_->serialPrintln("WeatherApp started");
last_read_ms_ = hal_->millis();
readAndReport();
}
// Call repeatedly from loop()
void update() {
unsigned long now = hal_->millis();
if (now - last_read_ms_ >= READ_INTERVAL_MS) {
readAndReport();
last_read_ms_ = now;
}
}
// -- Accessors for testing ------------------------------------------------
float lastCelsius() const { return last_celsius_; }
float lastFahrenheit() const { return last_fahrenheit_; }
int readCount() const { return read_count_; }
private:
void readAndReport() {
last_celsius_ = sensor_->readCelsius();
last_fahrenheit_ = sensor_->readFahrenheit();
read_count_++;
// Format: "Temperature: 23.5 C (74.3 F)"
// Use integer math for AVR compatibility (no %f on AVR printf)
char buf[48];
formatOneDp(buf, sizeof(buf), last_celsius_);
hal_->serialPrint("Temperature: ");
hal_->serialPrint(buf);
hal_->serialPrint(" C (");
formatOneDp(buf, sizeof(buf), last_fahrenheit_);
hal_->serialPrint(buf);
hal_->serialPrintln(" F)");
}
// Format a float as "23.5" or "-5.3" using integer math only.
static void formatOneDp(char* buf, size_t len, float value) {
int whole = (int)value;
int frac = (int)(value * 10.0f) % 10;
if (frac < 0) frac = -frac;
// Handle -0.x case (e.g. -0.3 C)
if (value < 0.0f && whole == 0) {
snprintf(buf, len, "-0.%d", frac);
} else {
snprintf(buf, len, "%d.%d", whole, frac);
}
}
Hal* hal_;
TempSensor* sensor_;
unsigned long last_read_ms_;
float last_celsius_;
float last_fahrenheit_;
int read_count_;
};
#endif // APP_H

View File

@@ -0,0 +1,25 @@
[template]
name = "weather"
base = "basic"
description = "Weather station with TMP36 temperature sensor"
[requires]
libraries = ["tmp36"]
board_capabilities = ["analog"]
# Default pin assignments per board.
# "default" is used when no board-specific section exists.
[pins.default]
tmp36_data = { pin = "A0", mode = "analog" }
[pins.uno]
tmp36_data = { pin = "A0", mode = "analog" }
[pins.mega]
tmp36_data = { pin = "A0", mode = "analog" }
[pins.nano]
tmp36_data = { pin = "A0", mode = "analog" }
[pins.leonardo]
tmp36_data = { pin = "A0", mode = "analog" }

View File

@@ -0,0 +1,28 @@
/*
* test_system.cpp -- Your system tests go here.
*
* This file is YOURS. Anvil will never overwrite it.
* The weather station example tests are in test_weather.cpp.
*
* System tests use SimHal and Tmp36Sim to exercise real application
* logic against simulated hardware. See test_weather.cpp for examples.
*/
#include <gtest/gtest.h>
#include "mock_arduino.h"
#include "hal.h"
#include "sim_hal.h"
#include "tmp36_sim.h"
#include "{{PROJECT_NAME}}_app.h"
// Example: add your own system tests below
// TEST(MySystemTests, DescribeWhatItTests) {
// mock_arduino_reset();
// SimHal sim;
// Tmp36Sim sensor(25.0f, 0.5f);
//
// WeatherApp app(&sim, &sensor);
// app.begin();
// // ... your test logic ...
// }

View File

@@ -0,0 +1,30 @@
/*
* test_unit.cpp -- Your unit tests go here.
*
* This file is YOURS. Anvil will never overwrite it.
* The weather station example tests are in test_weather.cpp.
*
* Unit tests use MockHal and Tmp36Mock to verify exact behavior
* without real hardware. See test_weather.cpp for examples.
*/
#include <gtest/gtest.h>
#include <gmock/gmock.h>
#include "hal.h"
#include "mock_hal.h"
#include "tmp36_mock.h"
#include "{{PROJECT_NAME}}_app.h"
using ::testing::_;
using ::testing::AnyNumber;
using ::testing::Return;
// Example: add your own tests below
// TEST(MyTests, DescribeWhatItTests) {
// ::testing::NiceMock<MockHal> mock;
// Tmp36Mock sensor;
// sensor.setTemperature(25.0f);
//
// // ... your test logic ...
// }

View File

@@ -0,0 +1,250 @@
/*
* test_weather.cpp -- Weather station example tests.
*
* THIS FILE IS MANAGED BY ANVIL and will be updated by `anvil refresh`.
* Do not edit -- put your own tests in test_unit.cpp and test_system.cpp.
*/
#include <gtest/gtest.h>
#include <gmock/gmock.h>
#include "mock_arduino.h"
#include "hal.h"
#include "mock_hal.h"
#include "sim_hal.h"
#include "tmp36_mock.h"
#include "tmp36_sim.h"
#include "{{PROJECT_NAME}}_app.h"
using ::testing::_;
using ::testing::AnyNumber;
using ::testing::Return;
using ::testing::HasSubstr;
// ============================================================================
// Unit Tests -- verify WeatherApp behavior with mock sensor
// ============================================================================
class WeatherUnitTest : public ::testing::Test {
protected:
void SetUp() override {
ON_CALL(mock_, millis()).WillByDefault(Return(0));
EXPECT_CALL(mock_, serialBegin(_)).Times(AnyNumber());
EXPECT_CALL(mock_, serialPrint(_)).Times(AnyNumber());
EXPECT_CALL(mock_, serialPrintln(_)).Times(AnyNumber());
EXPECT_CALL(mock_, millis()).Times(AnyNumber());
}
::testing::NiceMock<MockHal> mock_;
Tmp36Mock sensor_;
};
TEST_F(WeatherUnitTest, BeginPrintsStartupMessage) {
WeatherApp app(&mock_, &sensor_);
EXPECT_CALL(mock_, serialBegin(115200)).Times(1);
EXPECT_CALL(mock_, serialPrintln(HasSubstr("WeatherApp started"))).Times(1);
app.begin();
}
TEST_F(WeatherUnitTest, BeginTakesInitialReading) {
sensor_.setTemperature(25.0f);
WeatherApp app(&mock_, &sensor_);
app.begin();
EXPECT_EQ(app.readCount(), 1);
EXPECT_NEAR(app.lastCelsius(), 25.0f, 0.1f);
}
TEST_F(WeatherUnitTest, ReadsAfterInterval) {
sensor_.setTemperature(20.0f);
WeatherApp app(&mock_, &sensor_);
ON_CALL(mock_, millis()).WillByDefault(Return(0));
app.begin();
EXPECT_EQ(app.readCount(), 1);
// Not enough time yet
ON_CALL(mock_, millis()).WillByDefault(Return(1999));
app.update();
EXPECT_EQ(app.readCount(), 1);
// Now 2 seconds have passed
ON_CALL(mock_, millis()).WillByDefault(Return(2000));
app.update();
EXPECT_EQ(app.readCount(), 2);
}
TEST_F(WeatherUnitTest, DoesNotReadTooEarly) {
sensor_.setTemperature(22.0f);
WeatherApp app(&mock_, &sensor_);
ON_CALL(mock_, millis()).WillByDefault(Return(0));
app.begin();
ON_CALL(mock_, millis()).WillByDefault(Return(1500));
app.update();
EXPECT_EQ(app.readCount(), 1);
}
TEST_F(WeatherUnitTest, CelsiusToFahrenheitConversion) {
sensor_.setTemperature(0.0f);
WeatherApp app(&mock_, &sensor_);
app.begin();
EXPECT_NEAR(app.lastCelsius(), 0.0f, 0.1f);
EXPECT_NEAR(app.lastFahrenheit(), 32.0f, 0.1f);
}
TEST_F(WeatherUnitTest, BoilingPoint) {
sensor_.setTemperature(100.0f);
WeatherApp app(&mock_, &sensor_);
app.begin();
EXPECT_NEAR(app.lastCelsius(), 100.0f, 0.1f);
EXPECT_NEAR(app.lastFahrenheit(), 212.0f, 0.1f);
}
TEST_F(WeatherUnitTest, NegativeTemperature) {
sensor_.setTemperature(-10.0f);
WeatherApp app(&mock_, &sensor_);
app.begin();
EXPECT_NEAR(app.lastCelsius(), -10.0f, 0.1f);
EXPECT_NEAR(app.lastFahrenheit(), 14.0f, 0.1f);
}
TEST_F(WeatherUnitTest, PrintsTemperatureOnRead) {
sensor_.setTemperature(25.0f);
WeatherApp app(&mock_, &sensor_);
EXPECT_CALL(mock_, serialPrint(HasSubstr("Temperature: "))).Times(1);
EXPECT_CALL(mock_, serialPrintln(HasSubstr(" F)"))).Times(1);
app.begin();
}
TEST_F(WeatherUnitTest, MultipleReadingsTrackNewTemperature) {
WeatherApp app(&mock_, &sensor_);
sensor_.setTemperature(20.0f);
ON_CALL(mock_, millis()).WillByDefault(Return(0));
app.begin();
EXPECT_NEAR(app.lastCelsius(), 20.0f, 0.1f);
sensor_.setTemperature(30.0f);
ON_CALL(mock_, millis()).WillByDefault(Return(2000));
app.update();
EXPECT_NEAR(app.lastCelsius(), 30.0f, 0.1f);
EXPECT_EQ(app.readCount(), 2);
}
// ============================================================================
// System Tests -- exercise WeatherApp with simulated sensor and hardware
// ============================================================================
class WeatherSystemTest : public ::testing::Test {
protected:
void SetUp() override {
mock_arduino_reset();
sim_.setMillis(0);
}
SimHal sim_;
Tmp36Sim sensor_{25.0f}; // 25 C base temperature
};
TEST_F(WeatherSystemTest, StartsAndPrintsToSerial) {
WeatherApp app(&sim_, &sensor_);
app.begin();
std::string output = sim_.serialOutput();
EXPECT_NE(output.find("WeatherApp started"), std::string::npos);
EXPECT_NE(output.find("Temperature:"), std::string::npos);
}
TEST_F(WeatherSystemTest, InitialReadingIsReasonable) {
Tmp36Sim exact_sensor(25.0f, 0.0f); // zero noise
WeatherApp app(&sim_, &exact_sensor);
app.begin();
EXPECT_NEAR(app.lastCelsius(), 25.0f, 1.0f);
EXPECT_EQ(app.readCount(), 1);
}
TEST_F(WeatherSystemTest, ReadsAtTwoSecondIntervals) {
WeatherApp app(&sim_, &sensor_);
app.begin();
EXPECT_EQ(app.readCount(), 1);
// 1 second -- no new reading
sim_.advanceMillis(1000);
app.update();
EXPECT_EQ(app.readCount(), 1);
// 2 seconds -- new reading
sim_.advanceMillis(1000);
app.update();
EXPECT_EQ(app.readCount(), 2);
// 4 seconds -- another reading
sim_.advanceMillis(2000);
app.update();
EXPECT_EQ(app.readCount(), 3);
}
TEST_F(WeatherSystemTest, FiveMinuteRun) {
WeatherApp app(&sim_, &sensor_);
app.begin();
// Run 5 minutes at 100ms resolution
for (int i = 0; i < 3000; ++i) {
sim_.advanceMillis(100);
app.update();
}
// 5 minutes = 300 seconds / 2 second interval = 150 readings + 1 initial
EXPECT_EQ(app.readCount(), 151);
}
TEST_F(WeatherSystemTest, TemperatureChangeMidRun) {
Tmp36Sim sensor(20.0f, 0.0f); // start at 20 C, no noise
WeatherApp app(&sim_, &sensor);
app.begin();
EXPECT_NEAR(app.lastCelsius(), 20.0f, 1.0f);
// Change temperature and wait for next reading
sensor.setBaseTemperature(35.0f);
sim_.advanceMillis(2000);
app.update();
EXPECT_NEAR(app.lastCelsius(), 35.0f, 1.0f);
}
TEST_F(WeatherSystemTest, SerialOutputContainsFahrenheit) {
Tmp36Sim exact_sensor(0.0f, 0.0f); // 0 C = 32 F
WeatherApp app(&sim_, &exact_sensor);
app.begin();
std::string output = sim_.serialOutput();
EXPECT_NE(output.find("32"), std::string::npos)
<< "Should contain 32 F for 0 C: " << output;
}
TEST_F(WeatherSystemTest, NoisyReadingsStayInRange) {
Tmp36Sim noisy_sensor(25.0f, 2.0f); // +/- 2 C noise
noisy_sensor.setSeed(42);
WeatherApp app(&sim_, &noisy_sensor);
for (int i = 0; i < 20; ++i) {
sim_.setMillis(i * 2000);
if (i == 0) app.begin(); else app.update();
float c = app.lastCelsius();
EXPECT_GE(c, 20.0f) << "Reading " << i << " too low: " << c;
EXPECT_LE(c, 30.0f) << "Reading " << i << " too high: " << c;
}
}

2121
tests/test_refresh.rs Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff