diff --git a/src/commands/lib.rs b/src/commands/lib.rs index fe3b894..5262f55 100644 --- a/src/commands/lib.rs +++ b/src/commands/lib.rs @@ -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> { + 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) diff --git a/src/commands/new.rs b/src/commands/new.rs index 8bb1efa..c1f6409 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -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 = 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 --template {}", + info.name + ) + .bright_cyan() + ); + } println!(); } println!("{}", "Usage:".bright_yellow().bold()); println!(" anvil new "); - println!(" anvil new --template basic"); + println!( + " anvil new --template --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,40 +131,47 @@ pub fn create_project( // Resolve board preset let preset: &BoardPreset = match board { - Some(b) => { - match presets::find_preset(b) { - Some(p) => p, - None => { - println!( - "{}", - format!("Unknown board preset: '{}'", b).red().bold() - ); - println!(); - list_boards()?; - bail!("Invalid board preset"); - } + Some(b) => match presets::find_preset(b) { + Some(p) => p, + None => { + println!( + "{}", + format!("Unknown board preset: '{}'", b).red().bold() + ); + println!(); + 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,74 @@ 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); + // 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 +278,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,67 +411,103 @@ 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() ); - if cfg!(target_os = "windows") { + // For composed templates, wiring is step 2 + if template_name != "basic" { println!( - " 2. Compile: {}", - "build.bat".bright_cyan() - ); - println!( - " 3. Upload to board: {}", - "upload.bat".bright_cyan() - ); - println!( - " 4. Upload + monitor: {}", - "upload.bat --monitor".bright_cyan() - ); - println!( - " 5. Serial monitor: {}", - "monitor.bat".bright_cyan() - ); - println!( - " 6. Run host tests: {}", - "test\\run_tests.bat".bright_cyan() - ); - println!(); - println!( - " {}", - "On Linux/macOS: ./build.sh, ./upload.sh, ./monitor.sh" - .bright_black() + " 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 { - println!( - " 2. Compile: {}", - "./build.sh".bright_cyan() - ); - println!( - " 3. Upload to board: {}", - "./upload.sh".bright_cyan() - ); - println!( - " 4. Upload + monitor: {}", - "./upload.sh --monitor".bright_cyan() - ); - println!( - " 5. Serial monitor: {}", - "./monitor.sh".bright_cyan() - ); - println!( - " 6. Run host tests: {}", - "./test/run_tests.sh".bright_cyan() - ); - println!(); - println!( - " {}", - "On Windows: build.bat, upload.bat, monitor.bat, test\\run_tests.bat" - .bright_black() - ); + if cfg!(target_os = "windows") { + println!( + " 2. Compile: {}", + "build.bat".bright_cyan() + ); + println!( + " 3. Upload to board: {}", + "upload.bat".bright_cyan() + ); + println!( + " 4. Upload + monitor: {}", + "upload.bat --monitor".bright_cyan() + ); + println!( + " 5. Serial monitor: {}", + "monitor.bat".bright_cyan() + ); + println!( + " 6. Run host tests: {}", + "test\\run_tests.bat".bright_cyan() + ); + println!(); + println!( + " {}", + "On Linux/macOS: ./build.sh, ./upload.sh, ./monitor.sh" + .bright_black() + ); + } else { + println!( + " 2. Compile: {}", + "./build.sh".bright_cyan() + ); + println!( + " 3. Upload to board: {}", + "./upload.sh".bright_cyan() + ); + println!( + " 4. Upload + monitor: {}", + "./upload.sh --monitor".bright_cyan() + ); + println!( + " 5. Serial monitor: {}", + "./monitor.sh".bright_cyan() + ); + println!( + " 6. Run host tests: {}", + "./test/run_tests.sh".bright_cyan() + ); + println!(); + println!( + " {}", + "On Windows: build.bat, upload.bat, monitor.bat, \ + test\\run_tests.bat" + .bright_black() + ); + } } println!( diff --git a/src/commands/pin.rs b/src/commands/pin.rs index d08c991..1853f55 100644 --- a/src/commands/pin.rs +++ b/src/commands/pin.rs @@ -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 // ========================================================================= diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 7952ac7..de8aa48 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -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, + pub board_capabilities: Vec, +} + +/// 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, + pub board_capabilities: Vec, + pub pins: HashMap>, +} + +/// 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, + pins: Option>>, +} + +#[derive(Deserialize)] +struct TemplateHeader { + #[allow(dead_code)] + name: String, + base: String, + description: String, +} + +#[derive(Deserialize)] +struct TemplateRequires { + libraries: Option>, + board_capabilities: Option>, +} + +#[derive(Deserialize)] +struct PinDef { + pin: String, + mode: String, +} + +/// Parse template.toml from an embedded directory. +fn parse_template_toml(dir: &Dir<'_>) -> Option { + 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 { - 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 { - 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 { + 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> = HashMap::new(); + if let Some(pin_map) = toml.pins { + for (board, assignments) in pin_map { + let mut defs: Vec = 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 { 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 = 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" ); } } \ No newline at end of file diff --git a/templates/weather/__name__/__name__.ino.tmpl b/templates/weather/__name__/__name__.ino.tmpl new file mode 100644 index 0000000..1f0346e --- /dev/null +++ b/templates/weather/__name__/__name__.ino.tmpl @@ -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 +#include <{{PROJECT_NAME}}_app.h> +#include + +static ArduinoHal hw; +static Tmp36Analog sensor(&hw, A0); +static WeatherApp app(&hw, &sensor); + +void setup() { + app.begin(); +} + +void loop() { + app.update(); +} diff --git a/templates/weather/lib/app/__name___app.h.tmpl b/templates/weather/lib/app/__name___app.h.tmpl new file mode 100644 index 0000000..6b06b06 --- /dev/null +++ b/templates/weather/lib/app/__name___app.h.tmpl @@ -0,0 +1,100 @@ +#ifndef APP_H +#define APP_H + +#include +#include "tmp36.h" + +#include + +/* + * 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 diff --git a/templates/weather/template.toml b/templates/weather/template.toml new file mode 100644 index 0000000..5dc99ee --- /dev/null +++ b/templates/weather/template.toml @@ -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" } \ No newline at end of file diff --git a/templates/weather/test/test_system.cpp.tmpl b/templates/weather/test/test_system.cpp.tmpl new file mode 100644 index 0000000..f5d420e --- /dev/null +++ b/templates/weather/test/test_system.cpp.tmpl @@ -0,0 +1,119 @@ +#include + +#include "mock_arduino.h" +#include "hal.h" +#include "sim_hal.h" +#include "tmp36_sim.h" +#include "{{PROJECT_NAME}}_app.h" + +// ============================================================================ +// System Tests -- exercise WeatherApp with simulated sensor and hardware +// +// These tests use SimHal (simulated GPIO, timing, serial) and Tmp36Sim +// (simulated analog sensor with noise). No mocking expectations -- we +// observe real behavior through SimHal's inspection API. +// ============================================================================ + +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; + } +} diff --git a/templates/weather/test/test_unit.cpp.tmpl b/templates/weather/test/test_unit.cpp.tmpl new file mode 100644 index 0000000..30a7b98 --- /dev/null +++ b/templates/weather/test/test_unit.cpp.tmpl @@ -0,0 +1,132 @@ +#include +#include + +#include "hal.h" +#include "mock_hal.h" +#include "tmp36_mock.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 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); +} diff --git a/tests/test_template_weather.rs b/tests/test_template_weather.rs new file mode 100644 index 0000000..5c05213 --- /dev/null +++ b/tests/test_template_weather.rs @@ -0,0 +1,750 @@ +use anvil::commands; +use anvil::library; +use anvil::project::config::ProjectConfig; +use anvil::templates::{TemplateManager, TemplateContext}; +use std::fs; +use tempfile::TempDir; + +// ========================================================================= +// Template registry +// ========================================================================= + +#[test] +fn test_list_templates_includes_weather() { + let templates = TemplateManager::list_templates(); + assert!( + templates.iter().any(|t| t.name == "weather"), + "Weather template should be listed" + ); +} + +#[test] +fn test_weather_template_exists() { + assert!(TemplateManager::template_exists("weather")); +} + +#[test] +fn test_weather_is_not_default() { + let templates = TemplateManager::list_templates(); + let weather = templates.iter().find(|t| t.name == "weather").unwrap(); + assert!(!weather.is_default); +} + +#[test] +fn test_weather_lists_tmp36_library() { + 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_lists_analog_capability() { + let templates = TemplateManager::list_templates(); + let weather = templates.iter().find(|t| t.name == "weather").unwrap(); + assert!(weather.board_capabilities.contains(&"analog".to_string())); +} + +// ========================================================================= +// Composed metadata +// ========================================================================= + +#[test] +fn test_weather_composed_meta_exists() { + let meta = TemplateManager::composed_meta("weather"); + assert!(meta.is_some(), "Weather should have composed metadata"); +} + +#[test] +fn test_weather_composed_meta_base_is_basic() { + let meta = TemplateManager::composed_meta("weather").unwrap(); + assert_eq!(meta.base, "basic"); +} + +#[test] +fn test_weather_composed_meta_requires_tmp36() { + let meta = TemplateManager::composed_meta("weather").unwrap(); + assert!(meta.libraries.contains(&"tmp36".to_string())); +} + +#[test] +fn test_weather_composed_meta_has_pin_defaults() { + let meta = TemplateManager::composed_meta("weather").unwrap(); + assert!(!meta.pins.is_empty()); +} + +#[test] +fn test_weather_pins_for_uno() { + let meta = TemplateManager::composed_meta("weather").unwrap(); + let pins = meta.pins_for_board("uno"); + assert_eq!(pins.len(), 1); + assert_eq!(pins[0].name, "tmp36_data"); + assert_eq!(pins[0].pin, "A0"); + assert_eq!(pins[0].mode, "analog"); +} + +#[test] +fn test_weather_pins_fallback_to_default() { + let meta = TemplateManager::composed_meta("weather").unwrap(); + // "micro" is not explicitly listed, should fall back to "default" + let pins = meta.pins_for_board("micro"); + assert!(!pins.is_empty()); + assert!(pins.iter().any(|p| p.name == "tmp36_data")); +} + +#[test] +fn test_basic_has_no_composed_meta() { + assert!(TemplateManager::composed_meta("basic").is_none()); +} + +// ========================================================================= +// Template extraction -- file overlay +// ========================================================================= + +fn extract_weather(name: &str) -> TempDir { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: name.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(); + tmp +} + +#[test] +fn test_weather_has_basic_scaffold() { + let tmp = extract_weather("wx"); + assert!(tmp.path().join(".anvil.toml").exists()); + assert!(tmp.path().join("build.sh").exists()); + assert!(tmp.path().join("build.bat").exists()); + assert!(tmp.path().join("upload.sh").exists()); + assert!(tmp.path().join("monitor.sh").exists()); + assert!(tmp.path().join(".gitignore").exists()); + assert!(tmp.path().join("lib").join("hal").join("hal.h").exists()); +} + +#[test] +fn test_weather_has_weather_app() { + let tmp = extract_weather("wx"); + let app_path = tmp.path().join("lib").join("app").join("wx_app.h"); + assert!(app_path.exists(), "Weather app header should exist"); + let content = fs::read_to_string(&app_path).unwrap(); + assert!(content.contains("WeatherApp")); + assert!(content.contains("TempSensor")); + assert!(content.contains("readCelsius")); + assert!(content.contains("readFahrenheit")); + assert!(content.contains("READ_INTERVAL_MS")); +} + +#[test] +fn test_weather_app_replaces_basic_blink() { + let tmp = extract_weather("wx"); + let app_path = tmp.path().join("lib").join("app").join("wx_app.h"); + let content = fs::read_to_string(&app_path).unwrap(); + // Should NOT contain basic template's BlinkApp + assert!( + !content.contains("BlinkApp"), + "Weather app should replace basic blink, not include it" + ); +} + +#[test] +fn test_weather_has_weather_sketch() { + let tmp = extract_weather("wx"); + let ino_path = tmp.path().join("wx").join("wx.ino"); + assert!(ino_path.exists()); + let content = fs::read_to_string(&ino_path).unwrap(); + assert!(content.contains("Tmp36Analog")); + assert!(content.contains("WeatherApp")); + assert!(content.contains("hal_arduino.h")); + assert!(content.contains("tmp36_analog.h")); +} + +#[test] +fn test_weather_sketch_replaces_basic_sketch() { + let tmp = extract_weather("wx"); + let ino_path = tmp.path().join("wx").join("wx.ino"); + let content = fs::read_to_string(&ino_path).unwrap(); + assert!( + !content.contains("BlinkApp"), + "Weather sketch should replace basic, not extend it" + ); +} + +#[test] +fn test_weather_has_unit_tests() { + let tmp = extract_weather("wx"); + let test_path = tmp.path().join("test").join("test_unit.cpp"); + assert!(test_path.exists()); + let content = fs::read_to_string(&test_path).unwrap(); + assert!(content.contains("Tmp36Mock")); + assert!(content.contains("WeatherUnitTest")); + assert!(content.contains("wx_app.h")); +} + +#[test] +fn test_weather_has_system_tests() { + let tmp = extract_weather("wx"); + let test_path = tmp.path().join("test").join("test_system.cpp"); + assert!(test_path.exists()); + let content = fs::read_to_string(&test_path).unwrap(); + assert!(content.contains("Tmp36Sim")); + assert!(content.contains("WeatherSystemTest")); + assert!(content.contains("SimHal")); +} + +#[test] +fn test_weather_no_template_toml_in_output() { + let tmp = extract_weather("wx"); + assert!( + !tmp.path().join("template.toml").exists(), + "template.toml should be stripped from output" + ); +} + +#[test] +fn test_weather_preserves_cmake() { + let tmp = extract_weather("wx"); + let cmake = tmp.path().join("test").join("CMakeLists.txt"); + assert!(cmake.exists()); + let content = fs::read_to_string(&cmake).unwrap(); + assert!(content.contains("wx")); +} + +#[test] +fn test_weather_variable_substitution() { + let tmp = extract_weather("my_weather"); + // App header should use project name + let app_path = tmp + .path() + .join("lib") + .join("app") + .join("my_weather_app.h"); + assert!(app_path.exists()); + + // Sketch should use project name + let ino_path = tmp + .path() + .join("my_weather") + .join("my_weather.ino"); + assert!(ino_path.exists()); + + // Config should use project name + let config = fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap(); + assert!(config.contains("my_weather")); +} + +#[test] +fn test_weather_all_files_ascii() { + let tmp = extract_weather("wx"); + let mut checked = 0; + for entry in walkdir(tmp.path()) { + let content = fs::read(&entry).unwrap(); + for (i, &byte) in content.iter().enumerate() { + assert!( + byte < 128, + "Non-ASCII byte {} at offset {} in {}", + byte, + i, + entry.display() + ); + } + checked += 1; + } + assert!(checked > 0, "Should have checked some files"); +} + +/// Walk all files in a directory recursively. +fn walkdir(dir: &std::path::Path) -> Vec { + let mut files = vec![]; + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + files.extend(walkdir(&path)); + } else { + files.push(path); + } + } + } + files +} + +// ========================================================================= +// Library installation via install_library +// ========================================================================= + +#[test] +fn test_install_library_adds_tmp36_to_project() { + let tmp = extract_weather("wx"); + let written = + commands::lib::install_library("tmp36", tmp.path()).unwrap(); + assert!(!written.is_empty(), "Should write at least one file"); + + // Verify driver directory created + let driver_dir = tmp.path().join("lib").join("drivers").join("tmp36"); + assert!(driver_dir.exists()); + assert!(driver_dir.join("tmp36.h").exists()); + assert!(driver_dir.join("tmp36_analog.h").exists()); + assert!(driver_dir.join("tmp36_mock.h").exists()); + assert!(driver_dir.join("tmp36_sim.h").exists()); + + // Verify config updated + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert!(config.libraries.contains_key("tmp36")); + assert!(config.build.include_dirs.contains( + &"lib/drivers/tmp36".to_string() + )); +} + +#[test] +fn test_install_library_idempotent() { + let tmp = extract_weather("wx"); + let first = + commands::lib::install_library("tmp36", tmp.path()).unwrap(); + assert!(!first.is_empty()); + + // Second call should be a no-op + let second = + commands::lib::install_library("tmp36", tmp.path()).unwrap(); + assert!(second.is_empty(), "Second install should skip"); +} + +// ========================================================================= +// Pin assignment via install_pin_assignment +// ========================================================================= + +#[test] +fn test_install_pin_assignment_creates_assignment() { + let tmp = extract_weather("wx"); + // Need library installed first (for the include_dirs, not strictly + // needed for pin assignment but mirrors real flow) + commands::lib::install_library("tmp36", tmp.path()).unwrap(); + + commands::pin::install_pin_assignment( + "tmp36_data", + "A0", + "analog", + "uno", + tmp.path(), + ) + .unwrap(); + + // Verify config has the assignment + let config = ProjectConfig::load(tmp.path()).unwrap(); + let pins = config.pins.get("uno").expect("Should have uno pins"); + assert!(pins.assignments.contains_key("tmp36_data")); + let a = &pins.assignments["tmp36_data"]; + assert_eq!(a.pin, 14); // A0 = pin 14 on Uno + assert_eq!(a.mode, "analog"); +} + +#[test] +fn test_install_pin_assignment_rejects_bad_pin() { + let tmp = extract_weather("wx"); + let result = commands::pin::install_pin_assignment( + "tmp36_data", + "Z99", + "analog", + "uno", + tmp.path(), + ); + assert!(result.is_err()); +} + +#[test] +fn test_install_pin_assignment_rejects_bad_mode() { + let tmp = extract_weather("wx"); + let result = commands::pin::install_pin_assignment( + "tmp36_data", + "A0", + "bogus", + "uno", + tmp.path(), + ); + assert!(result.is_err()); +} + +// ========================================================================= +// Full composed template flow (extract + install libs + assign pins) +// ========================================================================= + +#[test] +fn test_weather_full_flow() { + let tmp = extract_weather("my_wx"); + + // Install libraries (as the new command would) + let meta = TemplateManager::composed_meta("weather").unwrap(); + for lib_name in &meta.libraries { + commands::lib::install_library(lib_name, tmp.path()).unwrap(); + } + + // Assign pins (as the new command would) + let pin_defaults = meta.pins_for_board("uno"); + for pin_def in &pin_defaults { + commands::pin::install_pin_assignment( + &pin_def.name, + &pin_def.pin, + &pin_def.mode, + "uno", + tmp.path(), + ) + .unwrap(); + } + + // Verify everything is in place + let config = ProjectConfig::load(tmp.path()).unwrap(); + + // Library installed + assert!(config.libraries.contains_key("tmp36")); + assert!(config.build.include_dirs.contains( + &"lib/drivers/tmp36".to_string() + )); + + // Pin assigned + let pins = config.pins.get("uno").expect("Should have uno pins"); + assert!(pins.assignments.contains_key("tmp36_data")); + + // Driver files present + assert!(tmp + .path() + .join("lib") + .join("drivers") + .join("tmp36") + .join("tmp36.h") + .exists()); + + // App code present (weather-specific, not blink) + let app_content = fs::read_to_string( + tmp.path().join("lib").join("app").join("my_wx_app.h"), + ) + .unwrap(); + assert!(app_content.contains("WeatherApp")); + assert!(!app_content.contains("BlinkApp")); + + // Test files present (weather-specific) + let unit_content = fs::read_to_string( + tmp.path().join("test").join("test_unit.cpp"), + ) + .unwrap(); + assert!(unit_content.contains("Tmp36Mock")); + + // Sketch wires everything together + let ino_content = fs::read_to_string( + tmp.path().join("my_wx").join("my_wx.ino"), + ) + .unwrap(); + assert!(ino_content.contains("Tmp36Analog")); + assert!(ino_content.contains("WeatherApp")); +} + +#[test] +fn test_weather_flow_with_mega() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "mega_wx".to_string(), + anvil_version: "1.0.0".to_string(), + board_name: "mega".to_string(), + fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(), + baud: 115200, + }; + TemplateManager::extract("weather", tmp.path(), &ctx).unwrap(); + + let meta = TemplateManager::composed_meta("weather").unwrap(); + for lib_name in &meta.libraries { + commands::lib::install_library(lib_name, tmp.path()).unwrap(); + } + + let pin_defaults = meta.pins_for_board("mega"); + for pin_def in &pin_defaults { + commands::pin::install_pin_assignment( + &pin_def.name, + &pin_def.pin, + &pin_def.mode, + "mega", + tmp.path(), + ) + .unwrap(); + } + + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert!(config.libraries.contains_key("tmp36")); + let pins = config.pins.get("mega").expect("Should have mega pins"); + assert!(pins.assignments.contains_key("tmp36_data")); +} + +// ========================================================================= +// Audit integration -- after template creation, audit should be clean +// ========================================================================= + +#[test] +fn test_weather_audit_clean_after_full_setup() { + let tmp = extract_weather("wx"); + + let meta = TemplateManager::composed_meta("weather").unwrap(); + for lib_name in &meta.libraries { + commands::lib::install_library(lib_name, tmp.path()).unwrap(); + } + for pin_def in meta.pins_for_board("uno") { + commands::pin::install_pin_assignment( + &pin_def.name, + &pin_def.pin, + &pin_def.mode, + "uno", + tmp.path(), + ) + .unwrap(); + } + + // Load config and check that library pins are all assigned + let config = ProjectConfig::load(tmp.path()).unwrap(); + let lib_meta = library::find_library("tmp36").unwrap(); + let pins = config.pins.get("uno").unwrap(); + let assigned_names: Vec = + pins.assignments.keys().cloned().collect(); + let unassigned = + library::unassigned_pins(&lib_meta, &assigned_names); + assert!( + unassigned.is_empty(), + "All library pins should be assigned after full setup, \ + but these are missing: {:?}", + unassigned + ); +} + +// ========================================================================= +// API compatibility -- template C++ must match library headers +// ========================================================================= + +/// Extract public method names from a C++ header (simple regex-free scan). +/// Looks for lines like: "void methodName(" or "float methodName(" or +/// "ClassName(" (constructors). +fn extract_public_methods(header: &str) -> Vec { + let mut methods = Vec::new(); + for line in header.lines() { + let trimmed = line.trim(); + // Skip comments, preprocessor, blank + if trimmed.starts_with("//") + || trimmed.starts_with("/*") + || trimmed.starts_with("*") + || trimmed.starts_with('#') + || trimmed.is_empty() + { + continue; + } + // Look for "word(" patterns that are method declarations + // e.g. "void setBaseTemperature(float celsius)" + // e.g. "Tmp36Sim(float base_temp = 22.0f, float noise = 0.5f)" + if let Some(paren_pos) = trimmed.find('(') { + let before = trimmed[..paren_pos].trim(); + // Last word before the paren is the method name + if let Some(name) = before.split_whitespace().last() { + // Skip class/struct declarations + if name == "class" || name == "struct" || name == "if" + || name == "for" || name == "while" + || name == "override" + { + continue; + } + methods.push(name.to_string()); + } + } + } + methods +} + +/// Count the number of default parameters in a constructor signature. +/// e.g. "Tmp36Sim(float base_temp = 22.0f, float noise = 0.5f)" +/// has 2 params, both with defaults. +fn count_constructor_params(header: &str, class_name: &str) -> (usize, usize) { + for line in header.lines() { + let trimmed = line.trim(); + if !trimmed.contains(&format!("{}(", class_name)) { + continue; + } + if let Some(start) = trimmed.find('(') { + if let Some(end) = trimmed.find(')') { + let params_str = &trimmed[start + 1..end]; + if params_str.trim().is_empty() { + return (0, 0); + } + let params: Vec<&str> = params_str.split(',').collect(); + let total = params.len(); + let with_defaults = + params.iter().filter(|p| p.contains('=')).count(); + return (total, with_defaults); + } + } + } + (0, 0) +} + +#[test] +fn test_template_system_tests_use_valid_sim_api() { + let tmp = extract_weather("wx"); + commands::lib::install_library("tmp36", tmp.path()).unwrap(); + + let sim_header = fs::read_to_string( + tmp.path() + .join("lib") + .join("drivers") + .join("tmp36") + .join("tmp36_sim.h"), + ) + .unwrap(); + + let test_source = fs::read_to_string( + tmp.path().join("test").join("test_system.cpp"), + ) + .unwrap(); + + let methods = extract_public_methods(&sim_header); + + // Verify test code only calls methods that exist in the header + // Check for common method-call patterns: "sensor.methodName(" + // or "exact_sensor.methodName(" etc. + for line in test_source.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("//") || trimmed.is_empty() { + continue; + } + // Find "identifier.methodName(" patterns + if let Some(dot_pos) = trimmed.find('.') { + let after_dot = &trimmed[dot_pos + 1..]; + if let Some(paren_pos) = after_dot.find('(') { + let method_name = after_dot[..paren_pos].trim(); + // Only check methods on sensor/sim-like objects + let before_dot = trimmed[..dot_pos].trim(); + let before_dot = before_dot + .split_whitespace() + .last() + .unwrap_or(before_dot); + if before_dot.contains("sensor") || before_dot.contains("sim") { + assert!( + methods.contains(&method_name.to_string()), + "test_system.cpp calls '{}.{}()' but '{}' \ + is not in tmp36_sim.h.\n \ + Available methods: {:?}", + before_dot, + method_name, + method_name, + methods + ); + } + } + } + } +} + +#[test] +fn test_template_unit_tests_use_valid_mock_api() { + let tmp = extract_weather("wx"); + commands::lib::install_library("tmp36", tmp.path()).unwrap(); + + let mock_header = fs::read_to_string( + tmp.path() + .join("lib") + .join("drivers") + .join("tmp36") + .join("tmp36_mock.h"), + ) + .unwrap(); + + let test_source = fs::read_to_string( + tmp.path().join("test").join("test_unit.cpp"), + ) + .unwrap(); + + let methods = extract_public_methods(&mock_header); + + for line in test_source.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("//") || trimmed.is_empty() { + continue; + } + if let Some(dot_pos) = trimmed.find('.') { + let after_dot = &trimmed[dot_pos + 1..]; + if let Some(paren_pos) = after_dot.find('(') { + let method_name = after_dot[..paren_pos].trim(); + let before_dot = trimmed[..dot_pos].trim(); + let before_dot = before_dot + .split_whitespace() + .last() + .unwrap_or(before_dot); + if before_dot.contains("sensor") { + assert!( + methods.contains(&method_name.to_string()), + "test_unit.cpp calls '{}.{}()' but '{}' \ + is not in tmp36_mock.h.\n \ + Available methods: {:?}", + before_dot, + method_name, + method_name, + methods + ); + } + } + } + } +} + +#[test] +fn test_template_sim_constructor_arg_count() { + let tmp = extract_weather("wx"); + commands::lib::install_library("tmp36", tmp.path()).unwrap(); + + let sim_header = fs::read_to_string( + tmp.path() + .join("lib") + .join("drivers") + .join("tmp36") + .join("tmp36_sim.h"), + ) + .unwrap(); + + let (total_params, default_params) = + count_constructor_params(&sim_header, "Tmp36Sim"); + + let test_source = fs::read_to_string( + tmp.path().join("test").join("test_system.cpp"), + ) + .unwrap(); + + // Check every "Tmp36Sim(" call in test code + let min_args = total_params - default_params; + for (line_num, line) in test_source.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.starts_with("//") { + continue; + } + // Find "Tmp36Sim(" constructor calls (not #include or class decl) + if let Some(pos) = trimmed.find("Tmp36Sim(") { + // Skip if it's a forward decl or class line + if trimmed.contains("class ") || trimmed.contains("#include") { + continue; + } + let after = &trimmed[pos + 9..]; // after "Tmp36Sim(" + if let Some(close) = after.find(')') { + let args_str = &after[..close]; + let arg_count = if args_str.trim().is_empty() { + 0 + } else { + args_str.split(',').count() + }; + assert!( + arg_count >= min_args && arg_count <= total_params, + "test_system.cpp line {}: Tmp36Sim() called with \ + {} args, but constructor accepts {}-{} args.\n \ + Line: {}", + line_num + 1, + arg_count, + min_args, + total_params, + trimmed + ); + } + } + } +} \ No newline at end of file