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..0fa4284 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,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,67 +422,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/commands/refresh.rs b/src/commands/refresh.rs index b1b41e2..d8133e3 100644 --- a/src/commands/refresh.rs +++ b/src/commands/refresh.rs @@ -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 { + 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 = Vec::new(); + let mut has_changes: Vec = 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,22 +176,90 @@ 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 " + .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(()); } + // 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); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&fresh, &dest) + .context(format!("Failed to write: {}", filename))?; + } + + // Make new shell scripts executable on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for filename in &will_create { + 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!( + "{} Created {} missing file(s).", + "ok".green(), + will_create.len() + ); + } + if !has_changes.is_empty() && !force { println!(); println!( - "{} {} script(s) differ from the latest Anvil templates.", + "{} {} file(s) differ from the latest Anvil templates.", "!".bright_yellow(), has_changes.len() ); @@ -152,54 +271,120 @@ pub fn run_refresh(project_dir: Option<&str>, force: bool) -> Result<()> { println!(); println!( " {}", - "Only build scripts are replaced. Your .anvil.toml and source code are never touched." + "Only managed files are replaced. Your source code \ + and .anvilignore files 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 { - 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)?; + // 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))?; } - 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(), + has_changes.len() + ); } - // Make shell scripts executable on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - for filename in &files_to_write { - 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); - } + 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()); } } } - println!(); - println!( - "{} Updated {} file(s).", - "ok".green(), - files_to_write.len() - ); - Ok(()) } \ No newline at end of file diff --git a/src/ignore.rs b/src/ignore.rs new file mode 100644 index 0000000..2991df1 --- /dev/null +++ b/src/ignore.rs @@ -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, +} + +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 { + 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 { + 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")); + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 9ae9952..0947697 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,4 +3,5 @@ pub mod commands; pub mod project; pub mod board; pub mod templates; -pub mod library; \ No newline at end of file +pub mod library; +pub mod ignore; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 93cb7cf..f529559 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,9 +81,21 @@ enum Commands { /// Path to project directory (defaults to current directory) dir: Option, - /// 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, + + /// Add a pattern to .anvilignore (protect a file from refresh) + #[arg(long, value_name = "PATTERN", conflicts_with_all = ["unignore", "force"])] + ignore: Option, + + /// Remove a pattern from .anvilignore (allow refresh to update it) + #[arg(long, value_name = "PATTERN", conflicts_with_all = ["ignore", "force"])] + unignore: Option, }, /// Manage board profiles in .anvil.toml @@ -264,11 +276,24 @@ fn main() -> Result<()> { commands::devices::scan_devices() } } - Commands::Refresh { dir, force } => { - commands::refresh::run_refresh( - dir.as_deref(), - 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 { diff --git a/src/project/config.rs b/src/project/config.rs index a31883e..5b980fa 100644 --- a/src/project/config.rs +++ b/src/project/config.rs @@ -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(), 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..e5836b9 --- /dev/null +++ b/templates/weather/test/test_system.cpp.tmpl @@ -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 + +#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 ... +// } diff --git a/templates/weather/test/test_unit.cpp.tmpl b/templates/weather/test/test_unit.cpp.tmpl new file mode 100644 index 0000000..6ba9ac1 --- /dev/null +++ b/templates/weather/test/test_unit.cpp.tmpl @@ -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 +#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; + +// Example: add your own tests below +// TEST(MyTests, DescribeWhatItTests) { +// ::testing::NiceMock mock; +// Tmp36Mock sensor; +// sensor.setTemperature(25.0f); +// +// // ... your test logic ... +// } diff --git a/templates/weather/test/test_weather.cpp.tmpl b/templates/weather/test/test_weather.cpp.tmpl new file mode 100644 index 0000000..3a72d47 --- /dev/null +++ b/templates/weather/test/test_weather.cpp.tmpl @@ -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 +#include + +#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 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; + } +} diff --git a/tests/test_refresh.rs b/tests/test_refresh.rs new file mode 100644 index 0000000..7e56e40 --- /dev/null +++ b/tests/test_refresh.rs @@ -0,0 +1,2121 @@ +/// Integration tests for `anvil refresh` and `.anvilignore`. +/// +/// Tests the full lifecycle: create project -> modify files -> refresh -> +/// verify ignore/update behavior across exact names, glob patterns, +/// --force, --file overrides, --ignore/--unignore management. + +use anvil::commands; +use anvil::ignore::{self, AnvilIgnore}; +use anvil::project::config::ProjectConfig; +use anvil::templates::{TemplateManager, TemplateContext}; +use std::fs; +use tempfile::TempDir; + +// ========================================================================= +// Helpers +// ========================================================================= + +/// Create a fully-wired weather project (extract + config + libs + pins + .anvilignore). +fn make_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(); + + let mut config = ProjectConfig::load(tmp.path()).unwrap(); + config.project.template = "weather".to_string(); + config.save(tmp.path()).unwrap(); + + ignore::generate_default(tmp.path(), "weather").unwrap(); + + 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(); + } + + tmp +} + +/// Create a basic project with .anvilignore. +fn make_basic(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("basic", tmp.path(), &ctx).unwrap(); + ignore::generate_default(tmp.path(), "basic").unwrap(); + tmp +} + +/// Shorthand for run_refresh with path string conversion. +fn refresh(dir: &std::path::Path, force: bool, file: Option<&str>) { + commands::refresh::run_refresh( + Some(dir.to_str().unwrap()), + force, + file, + ) + .unwrap(); +} + +/// Shorthand for add_ignore. +fn add_ignore(dir: &std::path::Path, pattern: &str) { + commands::refresh::add_ignore( + Some(dir.to_str().unwrap()), + pattern, + ) + .unwrap(); +} + +/// Shorthand for remove_ignore. +fn remove_ignore(dir: &std::path::Path, pattern: &str) { + commands::refresh::remove_ignore( + Some(dir.to_str().unwrap()), + pattern, + ) + .unwrap(); +} + +/// Tamper with a file by overwriting its contents. +fn tamper(dir: &std::path::Path, rel: &str, content: &str) { + let path = dir.join(rel); + fs::write(&path, content).unwrap(); +} + +/// Read a project file. +fn read(dir: &std::path::Path, rel: &str) -> String { + fs::read_to_string(dir.join(rel)).unwrap() +} + +/// Check if file contains a substring. +fn contains(dir: &std::path::Path, rel: &str, needle: &str) -> bool { + read(dir, rel).contains(needle) +} + +// ========================================================================= +// Refresh basics -- managed files updated, user files untouched +// ========================================================================= + +#[test] +fn test_fresh_project_nothing_to_refresh() { + // A freshly created project should have all managed files identical + // to the template. Refresh should find nothing to do. + let tmp = make_weather("wx"); + // No panic, no error + refresh(tmp.path(), false, None); +} + +#[test] +fn test_tampered_managed_script_detected_without_force() { + let tmp = make_weather("wx"); + tamper(tmp.path(), "build.sh", "#!/bin/bash\n# tampered\n"); + + // Without --force, run_refresh reports but does not overwrite + refresh(tmp.path(), false, None); + assert!( + contains(tmp.path(), "build.sh", "# tampered"), + "Without --force, tampered file should remain" + ); +} + +#[test] +fn test_force_restores_tampered_managed_script() { + let tmp = make_weather("wx"); + let original = read(tmp.path(), "build.sh"); + tamper(tmp.path(), "build.sh", "#!/bin/bash\n# tampered\n"); + + refresh(tmp.path(), true, None); + let after = read(tmp.path(), "build.sh"); + assert_eq!(after, original, "Force should restore build.sh"); +} + +#[test] +fn test_force_restores_tampered_mock_header() { + let tmp = make_weather("wx"); + tamper(tmp.path(), "test/mocks/mock_hal.h", "// tampered\n"); + + refresh(tmp.path(), true, None); + assert!( + contains(tmp.path(), "test/mocks/mock_hal.h", "MockHal"), + "Force should restore mock_hal.h" + ); +} + +#[test] +fn test_force_restores_tampered_cmake() { + let tmp = make_weather("wx"); + tamper(tmp.path(), "test/CMakeLists.txt", "# tampered\n"); + + refresh(tmp.path(), true, None); + assert!( + contains(tmp.path(), "test/CMakeLists.txt", "cmake_minimum_required"), + "Force should restore CMakeLists.txt" + ); +} + +#[test] +fn test_force_restores_tampered_hal() { + let tmp = make_weather("wx"); + tamper(tmp.path(), "lib/hal/hal.h", "// tampered\n"); + + refresh(tmp.path(), true, None); + assert!( + contains(tmp.path(), "lib/hal/hal.h", "class Hal"), + "Force should restore hal.h" + ); +} + +// ========================================================================= +// Ignored file survives --force +// ========================================================================= + +#[test] +fn test_ignored_test_unit_survives_force() { + let tmp = make_weather("wx"); + tamper(tmp.path(), "test/test_unit.cpp", "// my custom tests\n"); + + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "test/test_unit.cpp"), + "// my custom tests\n", + "Ignored test_unit.cpp must survive --force" + ); +} + +#[test] +fn test_ignored_test_system_survives_force() { + let tmp = make_weather("wx"); + tamper(tmp.path(), "test/test_system.cpp", "// my system tests\n"); + + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "test/test_system.cpp"), + "// my system tests\n", + "Ignored test_system.cpp must survive --force" + ); +} + +#[test] +fn test_ignored_app_code_survives_force() { + let tmp = make_weather("wx"); + tamper(tmp.path(), "lib/app/wx_app.h", "// my custom app\n"); + + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "lib/app/wx_app.h"), + "// my custom app\n", + "Ignored app code must survive --force" + ); +} + +#[test] +fn test_ignored_config_survives_force() { + let tmp = make_weather("wx"); + let config_before = read(tmp.path(), ".anvil.toml"); + // Append a comment to config + let modified = format!("{}# user addition\n", config_before); + tamper(tmp.path(), ".anvil.toml", &modified); + + refresh(tmp.path(), true, None); + assert!( + contains(tmp.path(), ".anvil.toml", "# user addition"), + ".anvil.toml is ignored, user changes must survive" + ); +} + +// ========================================================================= +// Managed template example test (test_weather.cpp) IS refreshed +// ========================================================================= + +#[test] +fn test_managed_template_test_refreshed_by_force() { + let tmp = make_weather("wx"); + tamper( + tmp.path(), + "test/test_weather.cpp", + "// tampered\n", + ); + + refresh(tmp.path(), true, None); + assert!( + contains(tmp.path(), "test/test_weather.cpp", "WeatherUnitTest"), + "Managed test_weather.cpp should be restored by --force" + ); + assert!( + !contains(tmp.path(), "test/test_weather.cpp", "// tampered"), + "Tampered content should be gone" + ); +} + +// ========================================================================= +// Library driver headers are managed and refreshed +// ========================================================================= + +#[test] +fn test_library_header_refreshed_by_force() { + let tmp = make_weather("wx"); + tamper( + tmp.path(), + "lib/drivers/tmp36/tmp36.h", + "// tampered\n", + ); + + refresh(tmp.path(), true, None); + assert!( + contains(tmp.path(), "lib/drivers/tmp36/tmp36.h", "TempSensor"), + "Driver header should be restored" + ); +} + +#[test] +fn test_library_sim_header_refreshed_by_force() { + let tmp = make_weather("wx"); + tamper( + tmp.path(), + "lib/drivers/tmp36/tmp36_sim.h", + "// tampered\n", + ); + + refresh(tmp.path(), true, None); + assert!( + contains(tmp.path(), "lib/drivers/tmp36/tmp36_sim.h", "Tmp36Sim"), + "Sim header should be restored" + ); +} + +#[test] +fn test_library_mock_header_refreshed_by_force() { + let tmp = make_weather("wx"); + tamper( + tmp.path(), + "lib/drivers/tmp36/tmp36_mock.h", + "// tampered\n", + ); + + refresh(tmp.path(), true, None); + assert!( + contains(tmp.path(), "lib/drivers/tmp36/tmp36_mock.h", "Tmp36Mock"), + "Mock header should be restored" + ); +} + +#[test] +fn test_library_test_file_refreshed_by_force() { + let tmp = make_weather("wx"); + tamper(tmp.path(), "test/test_tmp36.cpp", "// tampered\n"); + + refresh(tmp.path(), true, None); + assert!( + contains(tmp.path(), "test/test_tmp36.cpp", "Tmp36"), + "Library test file should be restored" + ); +} + +// ========================================================================= +// --file override: force-update one specific ignored file +// ========================================================================= + +#[test] +fn test_file_override_restores_ignored_file() { + let tmp = make_weather("wx"); + tamper(tmp.path(), "test/test_unit.cpp", "// my custom code\n"); + + // Without --file, it stays + refresh(tmp.path(), true, None); + assert_eq!(read(tmp.path(), "test/test_unit.cpp"), "// my custom code\n"); + + // With --file, it gets restored + refresh(tmp.path(), true, Some("test/test_unit.cpp")); + assert!( + contains(tmp.path(), "test/test_unit.cpp", "Your unit tests go here"), + "File override should restore test_unit.cpp to template" + ); +} + +#[test] +fn test_file_override_only_affects_specified_file() { + let tmp = make_weather("wx"); + tamper(tmp.path(), "test/test_unit.cpp", "// custom unit\n"); + tamper(tmp.path(), "test/test_system.cpp", "// custom system\n"); + + // Override only test_unit.cpp + refresh(tmp.path(), true, Some("test/test_unit.cpp")); + + // test_unit.cpp restored + assert!( + contains(tmp.path(), "test/test_unit.cpp", "Your unit tests go here"), + ); + // test_system.cpp still has custom content (still ignored) + assert_eq!( + read(tmp.path(), "test/test_system.cpp"), + "// custom system\n", + "Other ignored files should remain untouched" + ); +} + +#[test] +fn test_file_override_with_backslash_path() { + let tmp = make_weather("wx"); + tamper(tmp.path(), "test/test_unit.cpp", "// custom\n"); + + // Windows-style path should work + refresh(tmp.path(), true, Some("test\\test_unit.cpp")); + assert!( + contains(tmp.path(), "test/test_unit.cpp", "Your unit tests go here"), + "Backslash path should be normalized" + ); +} + +#[test] +fn test_file_override_for_app_code() { + let tmp = make_weather("wx"); + tamper(tmp.path(), "lib/app/wx_app.h", "// my custom app\n"); + + // Normally ignored via lib/app/* + refresh(tmp.path(), true, None); + assert_eq!(read(tmp.path(), "lib/app/wx_app.h"), "// my custom app\n"); + + // Force override for this specific file + refresh(tmp.path(), true, Some("lib/app/wx_app.h")); + assert!( + contains(tmp.path(), "lib/app/wx_app.h", "WeatherApp"), + "File override should restore app code" + ); +} + +// ========================================================================= +// Full lifecycle: ignore -> modify -> force -> still protected -> +// unignore -> force -> restored +// ========================================================================= + +#[test] +fn test_lifecycle_ignore_modify_force_unignore_force() { + let tmp = make_weather("wx"); + let original = read(tmp.path(), "test/test_unit.cpp"); + + // Step 1: test_unit.cpp is already ignored by default + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.is_ignored("test/test_unit.cpp")); + + // Step 2: Modify it + tamper(tmp.path(), "test/test_unit.cpp", "// student work\n"); + + // Step 3: Force refresh -- file is protected + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "test/test_unit.cpp"), + "// student work\n", + "Protected file must survive force" + ); + + // Step 4: Unignore it + remove_ignore(tmp.path(), "test/test_unit.cpp"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(!ignore.is_ignored("test/test_unit.cpp")); + + // Step 5: Force refresh -- now it gets restored + refresh(tmp.path(), true, None); + let after = read(tmp.path(), "test/test_unit.cpp"); + assert_eq!( + after, original, + "After unignore + force, file should be restored to template" + ); +} + +#[test] +fn test_lifecycle_add_custom_ignore_then_remove() { + let tmp = make_weather("wx"); + + // build.sh is normally managed (not ignored) + let original = read(tmp.path(), "build.sh"); + tamper(tmp.path(), "build.sh", "#!/bin/bash\n# my custom build\n"); + + // Force refresh restores it + refresh(tmp.path(), true, None); + assert_eq!(read(tmp.path(), "build.sh"), original); + + // Now add build.sh to .anvilignore + add_ignore(tmp.path(), "build.sh"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.is_ignored("build.sh")); + + // Tamper again + tamper(tmp.path(), "build.sh", "#!/bin/bash\n# my custom build\n"); + + // Force refresh -- now protected + refresh(tmp.path(), true, None); + assert!( + contains(tmp.path(), "build.sh", "# my custom build"), + "After adding to ignore, build.sh should be protected" + ); + + // Remove from ignore + remove_ignore(tmp.path(), "build.sh"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(!ignore.is_ignored("build.sh")); + + // Force refresh -- restored + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "build.sh"), + original, + "After removing from ignore, build.sh should be restored" + ); +} + +// ========================================================================= +// Glob pattern protection +// ========================================================================= + +#[test] +fn test_glob_pattern_protects_multiple_files() { + let tmp = make_weather("wx"); + + // Add a glob pattern to protect all mock files + add_ignore(tmp.path(), "test/mocks/mock_*.h"); + + // Tamper with mock files + tamper(tmp.path(), "test/mocks/mock_hal.h", "// custom mock\n"); + tamper(tmp.path(), "test/mocks/mock_arduino.h", "// custom arduino\n"); + + // Force refresh -- both protected by glob + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "test/mocks/mock_hal.h"), + "// custom mock\n", + "mock_hal.h should be protected by test/mocks/mock_*.h" + ); + assert_eq!( + read(tmp.path(), "test/mocks/mock_arduino.h"), + "// custom arduino\n", + "mock_arduino.h should be protected by test/mocks/mock_*.h" + ); + + // sim_hal.h is NOT matched by mock_*.h, so it should be restored + tamper(tmp.path(), "test/mocks/sim_hal.h", "// tampered sim\n"); + refresh(tmp.path(), true, None); + assert!( + contains(tmp.path(), "test/mocks/sim_hal.h", "SimHal"), + "sim_hal.h does not match mock_*.h pattern, should be restored" + ); +} + +#[test] +fn test_glob_pattern_file_override_punches_through() { + let tmp = make_weather("wx"); + + // Add glob pattern + add_ignore(tmp.path(), "test/mocks/mock_*.h"); + tamper(tmp.path(), "test/mocks/mock_hal.h", "// custom\n"); + + // Force with glob in place -- protected + refresh(tmp.path(), true, None); + assert_eq!(read(tmp.path(), "test/mocks/mock_hal.h"), "// custom\n"); + + // File override punches through the glob + refresh(tmp.path(), true, Some("test/mocks/mock_hal.h")); + assert!( + contains(tmp.path(), "test/mocks/mock_hal.h", "MockHal"), + "--file should override glob pattern protection" + ); +} + +#[test] +fn test_glob_star_cpp_protects_all_test_files() { + let tmp = make_weather("wx"); + + // Nuclear option: protect ALL .cpp files in test/ + add_ignore(tmp.path(), "test/*.cpp"); + + // Tamper with everything + tamper(tmp.path(), "test/test_weather.cpp", "// custom weather\n"); + tamper(tmp.path(), "test/test_tmp36.cpp", "// custom tmp36\n"); + + // Force refresh -- all protected + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "test/test_weather.cpp"), + "// custom weather\n", + ); + assert_eq!( + read(tmp.path(), "test/test_tmp36.cpp"), + "// custom tmp36\n", + ); + + // But scripts are NOT .cpp, so they get refreshed + tamper(tmp.path(), "build.sh", "#!/bin/bash\n# tampered\n"); + refresh(tmp.path(), true, None); + assert!( + !contains(tmp.path(), "build.sh", "# tampered"), + "build.sh is not .cpp, should be refreshed" + ); +} + +#[test] +fn test_question_mark_wildcard_in_pattern() { + let tmp = make_weather("wx"); + + // Protect files matching test_???36.cpp (test_tmp36.cpp matches) + add_ignore(tmp.path(), "test/test_???36.cpp"); + + tamper(tmp.path(), "test/test_tmp36.cpp", "// custom\n"); + + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "test/test_tmp36.cpp"), + "// custom\n", + "? wildcard should match single char" + ); +} + +// ========================================================================= +// Pattern management via add_ignore / remove_ignore +// ========================================================================= + +#[test] +fn test_add_ignore_creates_file_if_missing() { + let tmp = make_basic("bp"); + fs::remove_file(tmp.path().join(".anvilignore")).ok(); + assert!(!tmp.path().join(".anvilignore").exists()); + + add_ignore(tmp.path(), "custom.txt"); + assert!(tmp.path().join(".anvilignore").exists()); + + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.has_pattern("custom.txt")); +} + +#[test] +fn test_add_ignore_idempotent() { + let tmp = make_weather("wx"); + add_ignore(tmp.path(), "test/test_unit.cpp"); + add_ignore(tmp.path(), "test/test_unit.cpp"); + + let content = read(tmp.path(), ".anvilignore"); + let count = content + .lines() + .filter(|l| l.trim() == "test/test_unit.cpp") + .count(); + assert_eq!(count, 1, "Should not duplicate pattern"); +} + +#[test] +fn test_add_ignore_normalizes_backslash() { + let tmp = make_weather("wx"); + add_ignore(tmp.path(), "test\\custom.cpp"); + + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.has_pattern("test/custom.cpp")); + assert!(ignore.is_ignored("test/custom.cpp")); + assert!(ignore.is_ignored("test\\custom.cpp")); +} + +#[test] +fn test_remove_ignore_that_exists() { + let tmp = make_weather("wx"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.has_pattern("test/test_unit.cpp")); + + remove_ignore(tmp.path(), "test/test_unit.cpp"); + + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(!ignore.has_pattern("test/test_unit.cpp")); +} + +#[test] +fn test_remove_ignore_preserves_other_patterns() { + let tmp = make_weather("wx"); + remove_ignore(tmp.path(), "test/test_unit.cpp"); + + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(!ignore.has_pattern("test/test_unit.cpp")); + // Other defaults should remain + assert!(ignore.has_pattern("test/test_system.cpp")); + assert!(ignore.has_pattern(".anvil.toml")); +} + +#[test] +fn test_remove_nonexistent_pattern_does_not_error() { + let tmp = make_weather("wx"); + // Should not panic or error + remove_ignore(tmp.path(), "does/not/exist.txt"); +} + +#[test] +fn test_add_then_remove_glob_pattern() { + let tmp = make_weather("wx"); + + add_ignore(tmp.path(), "test/my_*.cpp"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.is_ignored("test/my_custom.cpp")); + + remove_ignore(tmp.path(), "test/my_*.cpp"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(!ignore.is_ignored("test/my_custom.cpp")); +} + +// ========================================================================= +// No .anvilignore -- everything managed +// ========================================================================= + +#[test] +fn test_no_anvilignore_all_files_managed() { + let tmp = make_weather("wx"); + // Delete .anvilignore + fs::remove_file(tmp.path().join(".anvilignore")).unwrap(); + + // Tamper with a normally-ignored file + tamper(tmp.path(), "test/test_unit.cpp", "// custom\n"); + + // Force refresh -- with no .anvilignore, nothing is protected + refresh(tmp.path(), true, None); + assert!( + contains(tmp.path(), "test/test_unit.cpp", "Your unit tests go here"), + "Without .anvilignore, all managed files should be refreshed" + ); +} + +// ========================================================================= +// .anvilignore edge cases +// ========================================================================= + +#[test] +fn test_anvilignore_comments_and_blanks_ignored() { + let tmp = make_basic("bp"); + fs::write( + tmp.path().join(".anvilignore"), + "# This is a comment\n\ + \n\ + # Another comment\n\ + \n\ + build.sh\n\ + \n\ + # trailing comment\n", + ) + .unwrap(); + + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert_eq!(ignore.patterns().len(), 1); + assert!(ignore.is_ignored("build.sh")); + assert!(!ignore.is_ignored("# This is a comment")); +} + +#[test] +fn test_anvilignore_whitespace_trimmed() { + let tmp = make_basic("bp"); + fs::write( + tmp.path().join(".anvilignore"), + " build.sh \n test/test_unit.cpp\t\n", + ) + .unwrap(); + + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.is_ignored("build.sh")); + assert!(ignore.is_ignored("test/test_unit.cpp")); +} + +#[test] +fn test_anvilignore_backslash_in_pattern_normalized() { + let tmp = make_basic("bp"); + fs::write( + tmp.path().join(".anvilignore"), + "test\\test_unit.cpp\n", + ) + .unwrap(); + + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.is_ignored("test/test_unit.cpp")); + assert!(ignore.is_ignored("test\\test_unit.cpp")); +} + +#[test] +fn test_anvilignore_empty_file_protects_nothing() { + let tmp = make_weather("wx"); + fs::write(tmp.path().join(".anvilignore"), "").unwrap(); + + tamper(tmp.path(), "test/test_unit.cpp", "// custom\n"); + refresh(tmp.path(), true, None); + assert!( + contains(tmp.path(), "test/test_unit.cpp", "Your unit tests go here"), + "Empty .anvilignore protects nothing" + ); +} + +// ========================================================================= +// matching_pattern -- which rule matched (UX reporting) +// ========================================================================= + +#[test] +fn test_matching_pattern_exact_name() { + let tmp = make_weather("wx"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert_eq!( + ignore.matching_pattern("test/test_unit.cpp"), + Some("test/test_unit.cpp"), + ); +} + +#[test] +fn test_matching_pattern_glob() { + let tmp = make_weather("wx"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert_eq!( + ignore.matching_pattern("lib/app/wx_app.h"), + Some("lib/app/*"), + ); +} + +#[test] +fn test_matching_pattern_none_for_managed() { + let tmp = make_weather("wx"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert_eq!(ignore.matching_pattern("build.sh"), None); + assert_eq!(ignore.matching_pattern("test/mocks/mock_hal.h"), None); +} + +#[test] +fn test_matching_pattern_first_pattern_wins() { + let tmp = make_basic("bp"); + fs::write( + tmp.path().join(".anvilignore"), + "test/test_unit.cpp\ntest/test_*.cpp\n", + ) + .unwrap(); + + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + // Exact match comes first in the file, so it wins + assert_eq!( + ignore.matching_pattern("test/test_unit.cpp"), + Some("test/test_unit.cpp"), + ); + // Only matches the glob + assert_eq!( + ignore.matching_pattern("test/test_system.cpp"), + Some("test/test_*.cpp"), + ); +} + +// ========================================================================= +// Multiple patterns stacking +// ========================================================================= + +#[test] +fn test_multiple_exact_patterns() { + let tmp = make_basic("bp"); + fs::write( + tmp.path().join(".anvilignore"), + "build.sh\nupload.sh\nmonitor.sh\n", + ) + .unwrap(); + + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.is_ignored("build.sh")); + assert!(ignore.is_ignored("upload.sh")); + assert!(ignore.is_ignored("monitor.sh")); + assert!(!ignore.is_ignored("build.bat")); +} + +#[test] +fn test_overlapping_exact_and_glob() { + let tmp = make_basic("bp"); + fs::write( + tmp.path().join(".anvilignore"), + "build.sh\n*.sh\n", + ) + .unwrap(); + + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.is_ignored("build.sh")); // matched by both + assert!(ignore.is_ignored("upload.sh")); // matched by glob + assert!(!ignore.is_ignored("build.bat")); // not .sh +} + +// ========================================================================= +// Config template field +// ========================================================================= + +#[test] +fn test_basic_project_template_defaults_to_basic() { + let tmp = make_basic("bp"); + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(config.project.template, "basic"); +} + +#[test] +fn test_weather_project_template_is_weather() { + let tmp = make_weather("wx"); + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(config.project.template, "weather"); +} + +#[test] +fn test_template_field_survives_save_reload() { + let tmp = make_weather("wx"); + let mut config = ProjectConfig::load(tmp.path()).unwrap(); + config.project.template = "custom".to_string(); + config.save(tmp.path()).unwrap(); + + let reloaded = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(reloaded.project.template, "custom"); +} + +// ========================================================================= +// Multi-step scenario: full student workflow +// ========================================================================= + +#[test] +fn test_student_workflow_scenario() { + // Simulate a student's journey: + // 1. Create project + // 2. Write custom tests + // 3. Anvil upgrades, student runs refresh + // 4. Student's tests survive + // 5. Student wants to reset one file + // 6. Student adds protection for a custom helper + + // Step 1: Create project + let tmp = make_weather("wx"); + + // Step 2: Student writes custom test code + tamper( + tmp.path(), + "test/test_unit.cpp", + "#include \n\ + TEST(MyTemp, ReadsCorrectly) {\n\ + \x20 // student's real work\n\ + \x20 EXPECT_TRUE(true);\n\ + }\n", + ); + tamper( + tmp.path(), + "test/test_system.cpp", + "#include \n\ + TEST(MySys, RunsForAnHour) {\n\ + \x20 // student's system test\n\ + \x20 EXPECT_TRUE(true);\n\ + }\n", + ); + + // Step 3: Anvil upgrades. Simulate by tampering a managed file. + tamper(tmp.path(), "build.sh", "#!/bin/bash\n# old version\n"); + tamper(tmp.path(), "test/test_weather.cpp", "// old example\n"); + + // Student runs refresh + refresh(tmp.path(), true, None); + + // Step 4: Student's tests survive + assert!( + contains(tmp.path(), "test/test_unit.cpp", "ReadsCorrectly"), + "Student's unit test must survive" + ); + assert!( + contains(tmp.path(), "test/test_system.cpp", "RunsForAnHour"), + "Student's system test must survive" + ); + + // Managed files are restored + assert!( + !contains(tmp.path(), "build.sh", "# old version"), + "build.sh should be refreshed" + ); + assert!( + contains(tmp.path(), "test/test_weather.cpp", "WeatherUnitTest"), + "test_weather.cpp should be refreshed" + ); + + // Step 5: Student wants to reset test_system.cpp to the template starter + refresh(tmp.path(), true, Some("test/test_system.cpp")); + assert!( + contains(tmp.path(), "test/test_system.cpp", "Your system tests go here"), + "File override should reset test_system.cpp" + ); + // test_unit.cpp still has student's work + assert!( + contains(tmp.path(), "test/test_unit.cpp", "ReadsCorrectly"), + "test_unit.cpp should still have student's work" + ); + + // Step 6: Student adds protection for a custom file they created + fs::write( + tmp.path().join("test").join("test_helpers.cpp"), + "// shared helpers\n", + ) + .unwrap(); + add_ignore(tmp.path(), "test/test_helpers.cpp"); + + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.is_ignored("test/test_helpers.cpp")); +} + +// ========================================================================= +// Refresh with basic template (no libraries, no template test) +// ========================================================================= + +#[test] +fn test_basic_project_refresh_works() { + let tmp = make_basic("bp"); + tamper(tmp.path(), "build.sh", "#!/bin/bash\n# tampered\n"); + + refresh(tmp.path(), true, None); + assert!( + !contains(tmp.path(), "build.sh", "# tampered"), + "build.sh should be restored for basic project" + ); +} + +#[test] +fn test_basic_project_ignores_student_tests() { + let tmp = make_basic("bp"); + tamper(tmp.path(), "test/test_unit.cpp", "// my custom\n"); + + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "test/test_unit.cpp"), + "// my custom\n", + "Basic project test_unit.cpp should be protected" + ); +} + +// ========================================================================= +// .anvilignore itself is never refreshed or overwritten +// ========================================================================= + +#[test] +fn test_anvilignore_itself_not_refreshed() { + let tmp = make_weather("wx"); + let _original = read(tmp.path(), ".anvilignore"); + + // Add a custom pattern + add_ignore(tmp.path(), "my_custom_pattern"); + + // Force refresh + refresh(tmp.path(), true, None); + + // .anvilignore should still have the custom pattern + assert!( + contains(tmp.path(), ".anvilignore", "my_custom_pattern"), + ".anvilignore should not be overwritten by refresh" + ); +} + +// ========================================================================= +// Edge: delete a managed file, refresh recreates it +// ========================================================================= + +#[test] +fn test_refresh_recreates_deleted_managed_file() { + let tmp = make_weather("wx"); + let build_sh = tmp.path().join("build.sh"); + assert!(build_sh.exists()); + + fs::remove_file(&build_sh).unwrap(); + assert!(!build_sh.exists()); + + // Even without --force, missing files should be created + refresh(tmp.path(), false, None); + assert!(build_sh.exists(), "Deleted managed file should be recreated"); +} + +#[test] +fn test_refresh_recreates_deleted_mock_header() { + let tmp = make_weather("wx"); + let mock = tmp.path().join("test").join("mocks").join("mock_hal.h"); + fs::remove_file(&mock).unwrap(); + + refresh(tmp.path(), false, None); + assert!(mock.exists(), "Deleted mock header should be recreated"); +} + +// ========================================================================= +// ASCII check +// ========================================================================= + +#[test] +fn test_all_managed_files_ascii_after_refresh() { + let tmp = make_weather("wx"); + + // Tamper and force refresh to exercise the full write path + tamper(tmp.path(), "build.sh", "#!/bin/bash\n"); + tamper(tmp.path(), "test/test_weather.cpp", "// x\n"); + refresh(tmp.path(), true, None); + + // Check every file under test/ and lib/ for non-ASCII + 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() + ); + } + } +} + +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 +} + +// ========================================================================= +// Multi-file simultaneous modification +// ========================================================================= + +#[test] +fn test_simultaneous_modifications_across_categories() { + // Tamper with files in every category at once and verify each + // category behaves correctly after --force. + let tmp = make_weather("wx"); + + // Category 1: managed scripts (should be restored) + tamper(tmp.path(), "build.sh", "#!/bin/bash\n# tampered\n"); + tamper(tmp.path(), "upload.sh", "#!/bin/bash\n# tampered\n"); + tamper(tmp.path(), "test.sh", "#!/bin/bash\n# tampered\n"); + + // Category 2: managed mocks (should be restored) + tamper(tmp.path(), "test/mocks/mock_hal.h", "// tampered mock_hal\n"); + tamper(tmp.path(), "test/mocks/sim_hal.h", "// tampered sim_hal\n"); + + // Category 3: managed template test (should be restored) + tamper(tmp.path(), "test/test_weather.cpp", "// tampered weather\n"); + + // Category 4: managed library headers (should be restored) + tamper( + tmp.path(), + "lib/drivers/tmp36/tmp36.h", + "// tampered driver\n", + ); + + // Category 5: managed HAL (should be restored) + tamper(tmp.path(), "lib/hal/hal.h", "// tampered hal\n"); + + // Category 6: ignored student files (should survive) + tamper(tmp.path(), "test/test_unit.cpp", "// student unit work\n"); + tamper(tmp.path(), "test/test_system.cpp", "// student system work\n"); + + // Category 7: ignored app code (should survive) + tamper(tmp.path(), "lib/app/wx_app.h", "// student app code\n"); + + // One big force refresh + refresh(tmp.path(), true, None); + + // Verify category 1: restored + assert!( + !contains(tmp.path(), "build.sh", "# tampered"), + "build.sh should be restored" + ); + assert!( + !contains(tmp.path(), "upload.sh", "# tampered"), + "upload.sh should be restored" + ); + assert!( + !contains(tmp.path(), "test.sh", "# tampered"), + "test.sh should be restored" + ); + + // Verify category 2: restored + assert!( + contains(tmp.path(), "test/mocks/mock_hal.h", "MockHal"), + "mock_hal.h should be restored" + ); + assert!( + contains(tmp.path(), "test/mocks/sim_hal.h", "SimHal"), + "sim_hal.h should be restored" + ); + + // Verify category 3: restored + assert!( + contains(tmp.path(), "test/test_weather.cpp", "WeatherUnitTest"), + "test_weather.cpp should be restored" + ); + + // Verify category 4: restored + assert!( + contains(tmp.path(), "lib/drivers/tmp36/tmp36.h", "TempSensor"), + "tmp36.h should be restored" + ); + + // Verify category 5: restored + assert!( + contains(tmp.path(), "lib/hal/hal.h", "class Hal"), + "hal.h should be restored" + ); + + // Verify category 6: survived + assert_eq!( + read(tmp.path(), "test/test_unit.cpp"), + "// student unit work\n", + ); + assert_eq!( + read(tmp.path(), "test/test_system.cpp"), + "// student system work\n", + ); + + // Verify category 7: survived + assert_eq!( + read(tmp.path(), "lib/app/wx_app.h"), + "// student app code\n", + ); +} + +// ========================================================================= +// Glob lifecycle: add glob -> modify -> force -> protected -> +// remove glob -> force -> restored +// ========================================================================= + +#[test] +fn test_glob_lifecycle_protect_then_unprotect() { + let tmp = make_weather("wx"); + + // Save originals for comparison + let orig_weather = read(tmp.path(), "test/test_weather.cpp"); + let orig_tmp36 = read(tmp.path(), "test/test_tmp36.cpp"); + + // Add glob that covers managed test files + add_ignore(tmp.path(), "test/test_*.cpp"); + + // Tamper managed test files (now protected by glob) + tamper(tmp.path(), "test/test_weather.cpp", "// student weather\n"); + tamper(tmp.path(), "test/test_tmp36.cpp", "// student tmp36\n"); + + // Force refresh -- both protected by glob + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "test/test_weather.cpp"), + "// student weather\n", + "Glob should protect test_weather.cpp" + ); + assert_eq!( + read(tmp.path(), "test/test_tmp36.cpp"), + "// student tmp36\n", + "Glob should protect test_tmp36.cpp" + ); + + // Remove the glob pattern + remove_ignore(tmp.path(), "test/test_*.cpp"); + + // Note: test_unit.cpp and test_system.cpp have their own exact patterns + // so they remain protected even after glob removal. + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!( + ignore.is_ignored("test/test_unit.cpp"), + "Exact pattern for test_unit should survive glob removal" + ); + assert!( + !ignore.is_ignored("test/test_weather.cpp"), + "test_weather should no longer be protected" + ); + + // Force refresh -- glob removed, managed files restored + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "test/test_weather.cpp"), + orig_weather, + "After glob removal, test_weather.cpp should be restored" + ); + assert_eq!( + read(tmp.path(), "test/test_tmp36.cpp"), + orig_tmp36, + "After glob removal, test_tmp36.cpp should be restored" + ); +} + +// ========================================================================= +// Pattern layering: exact + glob, remove exact -> still protected by glob +// ========================================================================= + +#[test] +fn test_pattern_layering_exact_plus_glob() { + let tmp = make_weather("wx"); + + // Default has exact "test/test_unit.cpp". + // Add a glob that also covers it. + add_ignore(tmp.path(), "test/test_*.cpp"); + + // Verify: matched by exact pattern first + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert_eq!( + ignore.matching_pattern("test/test_unit.cpp"), + Some("test/test_unit.cpp"), + "Exact pattern should match first" + ); + + // Remove the exact pattern + remove_ignore(tmp.path(), "test/test_unit.cpp"); + + // Still protected by the glob + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!( + ignore.is_ignored("test/test_unit.cpp"), + "Glob should still protect test_unit.cpp after exact removal" + ); + assert_eq!( + ignore.matching_pattern("test/test_unit.cpp"), + Some("test/test_*.cpp"), + "Now matched by glob pattern" + ); + + // Tamper and force -- still protected + tamper(tmp.path(), "test/test_unit.cpp", "// still protected\n"); + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "test/test_unit.cpp"), + "// still protected\n", + ); + + // Remove the glob too -- now unprotected + remove_ignore(tmp.path(), "test/test_*.cpp"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(!ignore.is_ignored("test/test_unit.cpp")); + + refresh(tmp.path(), true, None); + assert!( + contains(tmp.path(), "test/test_unit.cpp", "Your unit tests go here"), + "With both patterns gone, file should be restored" + ); +} + +// ========================================================================= +// Re-ignore after unignore: round-trip protection +// ========================================================================= + +#[test] +fn test_reignore_after_unignore() { + let tmp = make_weather("wx"); + let original = read(tmp.path(), "test/test_unit.cpp"); + + // Unignore + remove_ignore(tmp.path(), "test/test_unit.cpp"); + + // Tamper and force -- gets restored since unprotected + tamper(tmp.path(), "test/test_unit.cpp", "// custom\n"); + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "test/test_unit.cpp"), + original, + "Unprotected file should be restored" + ); + + // Re-add the ignore + add_ignore(tmp.path(), "test/test_unit.cpp"); + + // Now tamper and force -- protected again + tamper(tmp.path(), "test/test_unit.cpp", "// custom again\n"); + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "test/test_unit.cpp"), + "// custom again\n", + "Re-ignored file should be protected" + ); +} + +// ========================================================================= +// Multiple sequential force refreshes +// ========================================================================= + +#[test] +fn test_repeated_force_refreshes_idempotent() { + let tmp = make_weather("wx"); + let orig_build = read(tmp.path(), "build.sh"); + + // Tamper, force, tamper, force, tamper, force + for i in 0..3 { + tamper( + tmp.path(), + "build.sh", + &format!("#!/bin/bash\n# round {}\n", i), + ); + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "build.sh"), + orig_build, + "build.sh should be restored on round {}", + i + ); + } + + // Ignored file should survive all rounds + tamper(tmp.path(), "test/test_unit.cpp", "// stable\n"); + for _ in 0..3 { + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "test/test_unit.cpp"), + "// stable\n", + "Ignored file should survive every round" + ); + } +} + +// ========================================================================= +// --file with glob protection: only the named file is overridden +// ========================================================================= + +#[test] +fn test_file_override_with_glob_surgical_precision() { + let tmp = make_weather("wx"); + + // Glob-protect all mock headers + add_ignore(tmp.path(), "test/mocks/*.h"); + + // Tamper three mock headers + tamper(tmp.path(), "test/mocks/mock_hal.h", "// custom mock_hal\n"); + tamper(tmp.path(), "test/mocks/sim_hal.h", "// custom sim_hal\n"); + tamper(tmp.path(), "test/mocks/mock_arduino.h", "// custom arduino\n"); + + // Force with --file for just mock_hal.h + refresh(tmp.path(), true, Some("test/mocks/mock_hal.h")); + + // mock_hal.h restored, others still custom + assert!( + contains(tmp.path(), "test/mocks/mock_hal.h", "MockHal"), + "Named file should be restored" + ); + assert_eq!( + read(tmp.path(), "test/mocks/sim_hal.h"), + "// custom sim_hal\n", + "Non-named glob-protected file should survive" + ); + assert_eq!( + read(tmp.path(), "test/mocks/mock_arduino.h"), + "// custom arduino\n", + "Non-named glob-protected file should survive" + ); +} + +// ========================================================================= +// Whole-directory glob protection +// ========================================================================= + +#[test] +fn test_directory_glob_protects_all_children() { + let tmp = make_weather("wx"); + + // Protect the entire drivers tree + add_ignore(tmp.path(), "lib/drivers/*"); + + // Tamper all driver headers + tamper(tmp.path(), "lib/drivers/tmp36/tmp36.h", "// custom\n"); + tamper(tmp.path(), "lib/drivers/tmp36/tmp36_mock.h", "// custom\n"); + tamper(tmp.path(), "lib/drivers/tmp36/tmp36_sim.h", "// custom\n"); + + // Force -- all protected + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "lib/drivers/tmp36/tmp36.h"), + "// custom\n", + ); + assert_eq!( + read(tmp.path(), "lib/drivers/tmp36/tmp36_mock.h"), + "// custom\n", + ); + assert_eq!( + read(tmp.path(), "lib/drivers/tmp36/tmp36_sim.h"), + "// custom\n", + ); + + // --file override for just one + refresh(tmp.path(), true, Some("lib/drivers/tmp36/tmp36.h")); + assert!( + contains(tmp.path(), "lib/drivers/tmp36/tmp36.h", "TempSensor"), + "Named file should be restored through directory glob" + ); + assert_eq!( + read(tmp.path(), "lib/drivers/tmp36/tmp36_mock.h"), + "// custom\n", + "Other driver files still protected" + ); +} + +// ========================================================================= +// Sketch protection via default .anvilignore +// ========================================================================= + +#[test] +fn test_sketch_protected_by_default() { + let tmp = make_weather("wx"); + + // The default .anvilignore has a */*.ino pattern + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!( + ignore.is_ignored("wx/wx.ino"), + "Sketch .ino should be protected by default" + ); + + // Tamper sketch and force -- should survive + tamper(tmp.path(), "wx/wx.ino", "// student sketch\n"); + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "wx/wx.ino"), + "// student sketch\n", + "Sketch should survive force refresh" + ); +} + +// ========================================================================= +// .anvilignore itself is in NEVER_REFRESH, cannot be overwritten +// ========================================================================= + +#[test] +fn test_anvilignore_survives_even_if_not_in_patterns() { + let tmp = make_weather("wx"); + + // Write a completely custom .anvilignore + fs::write( + tmp.path().join(".anvilignore"), + "# my very custom ignore\nonly_this_one.txt\n", + ) + .unwrap(); + + // Force refresh + refresh(tmp.path(), true, None); + + // .anvilignore should still have our custom content + let content = read(tmp.path(), ".anvilignore"); + assert!( + content.contains("# my very custom ignore"), + ".anvilignore must never be overwritten by refresh" + ); + assert!( + content.contains("only_this_one.txt"), + "Custom patterns must survive" + ); +} + +#[test] +fn test_anvilignore_not_overwritten_even_with_file_flag() { + // Even --file .anvilignore should not work because NEVER_REFRESH + let tmp = make_weather("wx"); + fs::write( + tmp.path().join(".anvilignore"), + "# custom\n", + ) + .unwrap(); + + refresh(tmp.path(), true, Some(".anvilignore")); + let content = read(tmp.path(), ".anvilignore"); + assert_eq!( + content, "# custom\n", + ".anvilignore must survive even --file override" + ); +} + +// ========================================================================= +// Delete multiple managed files, recreated without --force +// ========================================================================= + +#[test] +fn test_multiple_deleted_files_recreated_without_force() { + let tmp = make_weather("wx"); + + // Delete several managed files + fs::remove_file(tmp.path().join("build.sh")).unwrap(); + fs::remove_file(tmp.path().join("upload.sh")).unwrap(); + fs::remove_file(tmp.path().join("test").join("run_tests.sh")).unwrap(); + fs::remove_file( + tmp.path().join("test").join("mocks").join("mock_hal.h"), + ) + .unwrap(); + + // Refresh without --force should recreate missing files + refresh(tmp.path(), false, None); + + assert!( + tmp.path().join("build.sh").exists(), + "build.sh should be recreated" + ); + assert!( + tmp.path().join("upload.sh").exists(), + "upload.sh should be recreated" + ); + assert!( + tmp.path().join("test").join("run_tests.sh").exists(), + "run_tests.sh should be recreated" + ); + assert!( + tmp.path() + .join("test") + .join("mocks") + .join("mock_hal.h") + .exists(), + "mock_hal.h should be recreated" + ); +} + +// ========================================================================= +// Delete an ignored file -- refresh does NOT recreate it (still ignored) +// ========================================================================= + +#[test] +fn test_deleted_ignored_file_not_recreated() { + let tmp = make_weather("wx"); + + // test_unit.cpp is ignored + let test_unit = tmp.path().join("test").join("test_unit.cpp"); + assert!(test_unit.exists()); + + fs::remove_file(&test_unit).unwrap(); + assert!(!test_unit.exists()); + + // Force refresh -- file is ignored so should NOT be recreated + refresh(tmp.path(), true, None); + assert!( + !test_unit.exists(), + "Deleted ignored file should NOT be recreated by refresh" + ); +} + +#[test] +fn test_deleted_ignored_file_recreated_with_file_override() { + let tmp = make_weather("wx"); + let test_unit = tmp.path().join("test").join("test_unit.cpp"); + fs::remove_file(&test_unit).unwrap(); + + // --file override should recreate even though ignored + refresh(tmp.path(), true, Some("test/test_unit.cpp")); + assert!( + test_unit.exists(), + "--file override should recreate deleted ignored file" + ); + assert!( + contains(tmp.path(), "test/test_unit.cpp", "Your unit tests go here"), + ); +} + +// ========================================================================= +// Without --force, tampered files are left alone (only missing created) +// ========================================================================= + +#[test] +fn test_no_force_tampered_file_left_alone_missing_created() { + let tmp = make_weather("wx"); + + // Tamper one file and delete another + tamper(tmp.path(), "build.sh", "#!/bin/bash\n# old\n"); + fs::remove_file(tmp.path().join("upload.sh")).unwrap(); + + refresh(tmp.path(), false, None); + + // Tampered file untouched (no --force) + assert!( + contains(tmp.path(), "build.sh", "# old"), + "Without --force, tampered file should remain" + ); + // Missing file recreated + assert!( + tmp.path().join("upload.sh").exists(), + "Missing file should be recreated even without --force" + ); +} + +// ========================================================================= +// Complex lifecycle: student works through the entire semester +// ========================================================================= + +#[test] +fn test_full_semester_lifecycle() { + // A realistic multi-step scenario simulating a student over time: + // + // Week 1: Create project, start writing tests + // Week 3: Anvil v2 released, student refreshes + // Week 5: Student protects a custom helper file + // Week 7: Student accidentally deletes a script, refreshes to recover + // Week 9: Student decides to reset their system test to starter + // Week 11: Student protects all their custom test files with a glob + // Week 13: Course ends, student unprotects everything for grading + + // == Week 1: Create and start writing == + let tmp = make_weather("station"); + + tamper( + tmp.path(), + "test/test_unit.cpp", + "#include \n\ + #include \"tmp36_mock.h\"\n\ + #include \"station_app.h\"\n\ + TEST(StationUnit, ReadsTemp) {\n\ + \x20 Tmp36Mock sensor;\n\ + \x20 sensor.setTemperature(22.5f);\n\ + \x20 EXPECT_NEAR(sensor.readCelsius(), 22.5f, 0.1f);\n\ + }\n", + ); + tamper( + tmp.path(), + "test/test_system.cpp", + "#include \n\ + TEST(StationSys, RunsOneMinute) {\n\ + \x20 // TODO: implement\n\ + \x20 EXPECT_TRUE(true);\n\ + }\n", + ); + + // == Week 3: Anvil v2 -- scripts change, student refreshes == + tamper(tmp.path(), "build.sh", "#!/bin/bash\n# v1 build\n"); + tamper(tmp.path(), "test/test_weather.cpp", "// v1 examples\n"); + + refresh(tmp.path(), true, None); + + // Student's tests survived + assert!( + contains(tmp.path(), "test/test_unit.cpp", "StationUnit"), + "Week 3: student unit tests must survive" + ); + assert!( + contains(tmp.path(), "test/test_system.cpp", "StationSys"), + "Week 3: student system tests must survive" + ); + // Managed files updated + assert!( + !contains(tmp.path(), "build.sh", "# v1 build"), + "Week 3: build.sh should be refreshed" + ); + assert!( + contains(tmp.path(), "test/test_weather.cpp", "WeatherUnitTest"), + "Week 3: test_weather.cpp should be refreshed" + ); + + // == Week 5: Student creates a helper and protects it == + fs::create_dir_all(tmp.path().join("test").join("helpers")).unwrap(); + fs::write( + tmp.path().join("test").join("helpers").join("test_utils.h"), + "// shared test utilities\n", + ) + .unwrap(); + add_ignore(tmp.path(), "test/helpers/*"); + + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.is_ignored("test/helpers/test_utils.h")); + + // == Week 7: Student accidentally deletes build.sh, recovers == + fs::remove_file(tmp.path().join("build.sh")).unwrap(); + refresh(tmp.path(), false, None); + assert!( + tmp.path().join("build.sh").exists(), + "Week 7: deleted script should be recreated" + ); + + // == Week 9: Student wants to restart system tests from template == + assert!( + contains(tmp.path(), "test/test_system.cpp", "StationSys"), + "Before reset, student's work is present" + ); + refresh(tmp.path(), true, Some("test/test_system.cpp")); + assert!( + contains( + tmp.path(), + "test/test_system.cpp", + "Your system tests go here" + ), + "Week 9: system test should be reset to starter" + ); + // Unit test still has student's work + assert!( + contains(tmp.path(), "test/test_unit.cpp", "StationUnit"), + "Week 9: unit test should still have student's work" + ); + + // == Week 11: Student adds glob for all their custom test files == + fs::write( + tmp.path().join("test").join("test_integration.cpp"), + "// student integration tests\n", + ) + .unwrap(); + add_ignore(tmp.path(), "test/test_integration.cpp"); + + // Verify protection stacking + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.is_ignored("test/test_unit.cpp")); + assert!(ignore.is_ignored("test/test_system.cpp")); + assert!(ignore.is_ignored("test/test_integration.cpp")); + assert!(ignore.is_ignored("test/helpers/test_utils.h")); + assert!(!ignore.is_ignored("test/test_weather.cpp")); + + // == Week 13: For grading, remove all custom protection == + remove_ignore(tmp.path(), "test/test_unit.cpp"); + remove_ignore(tmp.path(), "test/test_system.cpp"); + remove_ignore(tmp.path(), "test/test_integration.cpp"); + remove_ignore(tmp.path(), "test/helpers/*"); + + // Config and app code still protected by remaining patterns + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.is_ignored(".anvil.toml")); + assert!(ignore.is_ignored("lib/app/station_app.h")); + assert!(!ignore.is_ignored("test/test_unit.cpp")); +} + +// ========================================================================= +// Refresh correctly uses the config template field +// ========================================================================= + +#[test] +fn test_refresh_basic_does_not_have_template_test() { + let tmp = make_basic("bp"); + tamper(tmp.path(), "build.sh", "# tampered\n"); + + refresh(tmp.path(), true, None); + + // build.sh refreshed + assert!( + !contains(tmp.path(), "build.sh", "# tampered"), + ); + + // No test_basic.cpp should exist (basic template has no example test) + assert!( + !tmp.path().join("test").join("test_basic.cpp").exists(), + "Basic template should NOT produce a test_basic.cpp" + ); +} + +// ========================================================================= +// Refresh after config template change +// ========================================================================= + +#[test] +fn test_refresh_after_manual_template_change() { + // If someone changes config.project.template, refresh adapts. + // This is an edge case -- switching templates mid-project isn't + // officially supported, but refresh shouldn't crash. + let tmp = make_weather("wx"); + + // Change template to "basic" in config + let mut config = ProjectConfig::load(tmp.path()).unwrap(); + config.project.template = "basic".to_string(); + config.save(tmp.path()).unwrap(); + + // Refresh should still work (extracts basic template, not weather) + refresh(tmp.path(), true, None); + + // Should not crash -- that's the main assertion + assert!(tmp.path().join("build.sh").exists()); +} + +// ========================================================================= +// Edge: --force with no changes should be a no-op +// ========================================================================= + +#[test] +fn test_force_on_clean_project_is_noop() { + let tmp = make_weather("wx"); + + // Save content of a managed file + let before = read(tmp.path(), "build.sh"); + + // Force refresh on a clean project + refresh(tmp.path(), true, None); + + // Content should be byte-identical + let after = read(tmp.path(), "build.sh"); + assert_eq!(before, after, "Force on clean project should be a no-op"); +} + +// ========================================================================= +// Glob with nested directories +// ========================================================================= + +#[test] +fn test_glob_star_matches_nested_paths() { + let tmp = make_weather("wx"); + + // lib/drivers/tmp36/* should match files inside + add_ignore(tmp.path(), "lib/drivers/tmp36/*"); + + tamper(tmp.path(), "lib/drivers/tmp36/tmp36.h", "// protected\n"); + tamper(tmp.path(), "lib/drivers/tmp36/tmp36_sim.h", "// protected\n"); + + refresh(tmp.path(), true, None); + + assert_eq!( + read(tmp.path(), "lib/drivers/tmp36/tmp36.h"), + "// protected\n", + ); + assert_eq!( + read(tmp.path(), "lib/drivers/tmp36/tmp36_sim.h"), + "// protected\n", + ); +} + +// ========================================================================= +// Edge: adding multiple glob patterns that overlap +// ========================================================================= + +#[test] +fn test_multiple_overlapping_globs() { + let tmp = make_weather("wx"); + + add_ignore(tmp.path(), "test/*.cpp"); + add_ignore(tmp.path(), "test/test_*.cpp"); + + tamper(tmp.path(), "test/test_weather.cpp", "// protected\n"); + + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "test/test_weather.cpp"), + "// protected\n", + "File matched by multiple globs should be protected" + ); + + // Remove one glob -- still protected by the other + remove_ignore(tmp.path(), "test/test_*.cpp"); + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "test/test_weather.cpp"), + "// protected\n", + "Still protected by remaining glob" + ); + + // Remove the other glob -- now unprotected + remove_ignore(tmp.path(), "test/*.cpp"); + + // Note: exact pattern for test_unit and test_system still exists + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!( + !ignore.is_ignored("test/test_weather.cpp"), + "test_weather.cpp should now be unprotected" + ); + + refresh(tmp.path(), true, None); + assert!( + contains(tmp.path(), "test/test_weather.cpp", "WeatherUnitTest"), + "Unprotected file should be restored" + ); +} + +// ========================================================================= +// Edge: empty .anvilignore vs missing .anvilignore +// ========================================================================= + +#[test] +fn test_empty_vs_missing_anvilignore_equivalent() { + // Both should result in nothing being protected. + let tmp1 = make_weather("wx1"); + let tmp2 = make_weather("wx2"); + + // tmp1: empty .anvilignore + fs::write(tmp1.path().join(".anvilignore"), "").unwrap(); + // tmp2: delete .anvilignore + fs::remove_file(tmp2.path().join(".anvilignore")).unwrap(); + + // Tamper same file in both + tamper(tmp1.path(), "test/test_unit.cpp", "// custom\n"); + tamper(tmp2.path(), "test/test_unit.cpp", "// custom\n"); + + // Force refresh both + refresh(tmp1.path(), true, None); + refresh(tmp2.path(), true, None); + + // Both should have file restored (nothing protected) + assert!( + contains(tmp1.path(), "test/test_unit.cpp", "Your unit tests go here"), + "Empty .anvilignore: file should be restored" + ); + assert!( + contains(tmp2.path(), "test/test_unit.cpp", "Your unit tests go here"), + "Missing .anvilignore: file should be restored" + ); +} + +// ========================================================================= +// Edge: only comments in .anvilignore +// ========================================================================= + +#[test] +fn test_comments_only_anvilignore_protects_nothing() { + let tmp = make_weather("wx"); + fs::write( + tmp.path().join(".anvilignore"), + "# just comments\n# nothing real\n# more comments\n", + ) + .unwrap(); + + tamper(tmp.path(), "test/test_unit.cpp", "// custom\n"); + refresh(tmp.path(), true, None); + + assert!( + contains(tmp.path(), "test/test_unit.cpp", "Your unit tests go here"), + "Comments-only .anvilignore should protect nothing" + ); +} + +// ========================================================================= +// Verify ignore patterns persist across multiple refreshes +// ========================================================================= + +#[test] +fn test_added_patterns_persist_across_refreshes() { + let tmp = make_weather("wx"); + + add_ignore(tmp.path(), "test/test_weather.cpp"); + tamper(tmp.path(), "test/test_weather.cpp", "// custom\n"); + + // Refresh 1 + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "test/test_weather.cpp"), + "// custom\n", + ); + + // Refresh 2 -- pattern should still be in .anvilignore + refresh(tmp.path(), true, None); + assert_eq!( + read(tmp.path(), "test/test_weather.cpp"), + "// custom\n", + "Added patterns must persist across refreshes" + ); + + // Verify the pattern is still in the file + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.has_pattern("test/test_weather.cpp")); +} + +// ========================================================================= +// Edge: refresh with all scripts deleted AND modified files +// ========================================================================= + +#[test] +fn test_refresh_with_mixed_deleted_and_modified() { + let tmp = make_weather("wx"); + + // Delete some managed files + fs::remove_file(tmp.path().join("build.sh")).unwrap(); + fs::remove_file(tmp.path().join("upload.sh")).unwrap(); + + // Tamper others + tamper(tmp.path(), "monitor.sh", "#!/bin/bash\n# old monitor\n"); + tamper(tmp.path(), "test.sh", "#!/bin/bash\n# old test\n"); + + // Refresh without --force: missing files created, tampered left alone + refresh(tmp.path(), false, None); + + assert!(tmp.path().join("build.sh").exists(), "Deleted: recreated"); + assert!(tmp.path().join("upload.sh").exists(), "Deleted: recreated"); + assert!( + contains(tmp.path(), "monitor.sh", "# old monitor"), + "Tampered without --force: left alone" + ); + + // Now force + refresh(tmp.path(), true, None); + assert!( + !contains(tmp.path(), "monitor.sh", "# old monitor"), + "Tampered with --force: restored" + ); + assert!( + !contains(tmp.path(), "test.sh", "# old test"), + "Tampered with --force: restored" + ); +} + +// ========================================================================= +// Edge: tamper a .bat file (Windows script), verify restoration +// ========================================================================= + +#[test] +fn test_bat_scripts_managed_and_refreshed() { + let tmp = make_weather("wx"); + let orig = read(tmp.path(), "build.bat"); + + tamper(tmp.path(), "build.bat", "@echo off\nREM tampered\n"); + tamper(tmp.path(), "upload.bat", "@echo off\nREM tampered\n"); + + refresh(tmp.path(), true, None); + + assert_eq!( + read(tmp.path(), "build.bat"), + orig, + "build.bat should be restored" + ); + assert!( + !contains(tmp.path(), "upload.bat", "REM tampered"), + "upload.bat should be restored" + ); +} + +// ========================================================================= +// Edge: add a pattern, verify it's there, add it again, still only once +// ========================================================================= + +#[test] +fn test_add_ignore_truly_idempotent_with_refresh() { + let tmp = make_weather("wx"); + + // The default already has test/test_unit.cpp + add_ignore(tmp.path(), "test/test_unit.cpp"); + add_ignore(tmp.path(), "test/test_unit.cpp"); + add_ignore(tmp.path(), "test/test_unit.cpp"); + + let content = read(tmp.path(), ".anvilignore"); + let count = content + .lines() + .filter(|l| l.trim() == "test/test_unit.cpp") + .count(); + assert_eq!(count, 1, "Pattern must appear exactly once"); + + // Refresh still works fine + tamper(tmp.path(), "test/test_unit.cpp", "// custom\n"); + refresh(tmp.path(), true, None); + assert_eq!(read(tmp.path(), "test/test_unit.cpp"), "// custom\n"); +} + +// ========================================================================= +// Verify all scripts (.sh and .bat) are in the managed set +// ========================================================================= + +#[test] +fn test_all_scripts_are_managed() { + let tmp = make_weather("wx"); + + let scripts = [ + "build.sh", + "build.bat", + "upload.sh", + "upload.bat", + "monitor.sh", + "monitor.bat", + "test.sh", + "test.bat", + "test/run_tests.sh", + "test/run_tests.bat", + ]; + + for script in &scripts { + let orig = read(tmp.path(), script); + tamper(tmp.path(), script, "TAMPERED"); + refresh(tmp.path(), true, None); + + let after = read(tmp.path(), script); + assert_eq!( + after, orig, + "{} should be managed and restored by refresh", + script + ); + } +} \ No newline at end of file diff --git a/tests/test_template_weather.rs b/tests/test_template_weather.rs new file mode 100644 index 0000000..a3ff251 --- /dev/null +++ b/tests/test_template_weather.rs @@ -0,0 +1,1096 @@ +use anvil::commands; +use anvil::ignore::{self, AnvilIgnore}; +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_managed_example_tests() { + let tmp = extract_weather("wx"); + let test_path = tmp.path().join("test").join("test_weather.cpp"); + assert!(test_path.exists(), "Managed test_weather.cpp should exist"); + let content = fs::read_to_string(&test_path).unwrap(); + assert!(content.contains("Tmp36Mock")); + assert!(content.contains("WeatherUnitTest")); + assert!(content.contains("Tmp36Sim")); + assert!(content.contains("WeatherSystemTest")); + assert!(content.contains("wx_app.h")); + assert!(content.contains("MANAGED BY ANVIL")); +} + +#[test] +fn test_weather_has_student_unit_starter() { + 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(); + // Should be a minimal starter, not the full weather tests + assert!(content.contains("Your unit tests go here")); + assert!(!content.contains("WeatherUnitTest")); +} + +#[test] +fn test_weather_has_student_system_starter() { + 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("Your system tests go here")); + assert!(!content.contains("WeatherSystemTest")); +} + +#[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 weather_content = fs::read_to_string( + tmp.path().join("test").join("test_weather.cpp"), + ) + .unwrap(); + assert!(weather_content.contains("Tmp36Mock")); + assert!(weather_content.contains("Tmp36Sim")); + + // 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_weather_tests_use_valid_sensor_api() { + // test_weather.cpp includes BOTH unit tests (Tmp36Mock) and system tests + // (Tmp36Sim). Each sensor method call must exist in at least one header. + 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 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_weather.cpp"), + ) + .unwrap(); + + let sim_methods = extract_public_methods(&sim_header); + let mock_methods = extract_public_methods(&mock_header); + + // Union of all valid methods across both sensor types + let mut all_methods: Vec = sim_methods.clone(); + for m in &mock_methods { + if !all_methods.contains(m) { + all_methods.push(m.clone()); + } + } + + 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!( + all_methods.contains(&method_name.to_string()), + "test_weather.cpp calls '{}.{}()' but '{}' \ + is not in tmp36_sim.h or tmp36_mock.h.\n \ + Sim methods: {:?}\n \ + Mock methods: {:?}", + before_dot, + method_name, + method_name, + sim_methods, + mock_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_weather.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_weather.cpp line {}: Tmp36Sim() called with \ + {} args, but constructor accepts {}-{} args.\n \ + Line: {}", + line_num + 1, + arg_count, + min_args, + total_params, + trimmed + ); + } + } + } +} + +// ========================================================================= +// .anvilignore -- generated defaults and behavior +// ========================================================================= + +/// Helper: full weather project setup (extract + set template + libs + pins + .anvilignore) +fn setup_weather_project(name: &str) -> TempDir { + let tmp = extract_weather(name); + + // Set template field in config (as new.rs does) + let mut config = ProjectConfig::load(tmp.path()).unwrap(); + config.project.template = "weather".to_string(); + config.save(tmp.path()).unwrap(); + + // Generate .anvilignore + ignore::generate_default(tmp.path(), "weather").unwrap(); + + // Install library and assign pins + 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(); + } + + tmp +} + +#[test] +fn test_anvilignore_generated_on_project_creation() { + let tmp = setup_weather_project("wx"); + assert!( + tmp.path().join(".anvilignore").exists(), + ".anvilignore should be created" + ); +} + +#[test] +fn test_anvilignore_protects_student_test_files() { + let tmp = setup_weather_project("wx"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!( + ignore.is_ignored("test/test_unit.cpp"), + "test_unit.cpp should be protected" + ); + assert!( + ignore.is_ignored("test/test_system.cpp"), + "test_system.cpp should be protected" + ); +} + +#[test] +fn test_anvilignore_protects_app_code() { + let tmp = setup_weather_project("wx"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!( + ignore.is_ignored("lib/app/wx_app.h"), + "App code should be protected by lib/app/* pattern" + ); +} + +#[test] +fn test_anvilignore_protects_config() { + let tmp = setup_weather_project("wx"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!( + ignore.is_ignored(".anvil.toml"), + ".anvil.toml should be protected" + ); +} + +#[test] +fn test_anvilignore_does_not_protect_managed_scripts() { + let tmp = setup_weather_project("wx"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!( + !ignore.is_ignored("build.sh"), + "build.sh should NOT be protected" + ); + assert!( + !ignore.is_ignored("test/run_tests.sh"), + "run_tests.sh should NOT be protected" + ); + assert!( + !ignore.is_ignored("test/mocks/mock_hal.h"), + "mock_hal.h should NOT be protected" + ); +} + +#[test] +fn test_anvilignore_does_not_protect_managed_template_test() { + let tmp = setup_weather_project("wx"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!( + !ignore.is_ignored("test/test_weather.cpp"), + "test_weather.cpp is managed by Anvil, should NOT be protected" + ); +} + +#[test] +fn test_anvilignore_does_not_protect_library_headers() { + let tmp = setup_weather_project("wx"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!( + !ignore.is_ignored("lib/drivers/tmp36/tmp36.h"), + "Driver headers are managed, should NOT be protected" + ); +} + +// ========================================================================= +// .anvilignore -- add/remove patterns +// ========================================================================= + +#[test] +fn test_add_ignore_pattern() { + let tmp = setup_weather_project("wx"); + ignore::add_pattern(tmp.path(), "test/test_custom.cpp").unwrap(); + + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.is_ignored("test/test_custom.cpp")); +} + +#[test] +fn test_add_ignore_pattern_no_duplicate() { + let tmp = setup_weather_project("wx"); + ignore::add_pattern(tmp.path(), "test/test_unit.cpp").unwrap(); + + let content = + fs::read_to_string(tmp.path().join(".anvilignore")).unwrap(); + // test_unit.cpp appears in the default, adding it again should not duplicate + let count = content + .lines() + .filter(|l| l.trim() == "test/test_unit.cpp") + .count(); + assert_eq!(count, 1); +} + +#[test] +fn test_add_ignore_wildcard_pattern() { + let tmp = setup_weather_project("wx"); + ignore::add_pattern(tmp.path(), "test/my_*.cpp").unwrap(); + + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.is_ignored("test/my_custom_test.cpp")); + assert!(ignore.is_ignored("test/my_helper.cpp")); + assert!(!ignore.is_ignored("test/test_weather.cpp")); +} + +#[test] +fn test_remove_ignore_pattern() { + let tmp = setup_weather_project("wx"); + + // test_unit.cpp is in defaults + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.is_ignored("test/test_unit.cpp")); + + // Remove it + let removed = + ignore::remove_pattern(tmp.path(), "test/test_unit.cpp").unwrap(); + assert!(removed); + + // Verify it's gone + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(!ignore.is_ignored("test/test_unit.cpp")); +} + +#[test] +fn test_remove_nonexistent_pattern_returns_false() { + let tmp = setup_weather_project("wx"); + let removed = + ignore::remove_pattern(tmp.path(), "nonexistent.cpp").unwrap(); + assert!(!removed); +} + +// ========================================================================= +// .anvilignore -- matching_pattern reports which rule matched +// ========================================================================= + +#[test] +fn test_matching_pattern_reports_exact() { + let tmp = setup_weather_project("wx"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert_eq!( + ignore.matching_pattern("test/test_unit.cpp"), + Some("test/test_unit.cpp") + ); +} + +#[test] +fn test_matching_pattern_reports_glob() { + let tmp = setup_weather_project("wx"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + // lib/app/* should match lib/app/wx_app.h + let pattern = ignore.matching_pattern("lib/app/wx_app.h"); + assert_eq!(pattern, Some("lib/app/*")); +} + +#[test] +fn test_matching_pattern_returns_none_for_unignored() { + let tmp = setup_weather_project("wx"); + let ignore = AnvilIgnore::load(tmp.path()).unwrap(); + assert!(ignore.matching_pattern("build.sh").is_none()); +} + +// ========================================================================= +// Config tracks template name +// ========================================================================= + +#[test] +fn test_config_records_template_name() { + let tmp = setup_weather_project("wx"); + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(config.project.template, "weather"); +} + +#[test] +fn test_config_default_template_is_basic() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "basic_proj".to_string(), + anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), + fqbn: "arduino:avr:uno".to_string(), + baud: 115200, + }; + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(config.project.template, "basic"); +} + +// ========================================================================= +// Refresh respects .anvilignore +// ========================================================================= + +#[test] +fn test_refresh_does_not_overwrite_ignored_files() { + let tmp = setup_weather_project("wx"); + + // Modify the student's test_unit.cpp (which is ignored) + let test_unit = tmp.path().join("test").join("test_unit.cpp"); + fs::write(&test_unit, "// my custom test code\n").unwrap(); + + // Run refresh --force + commands::refresh::run_refresh( + Some(tmp.path().to_str().unwrap()), + true, + None, + ) + .unwrap(); + + // Student's file should be untouched + let content = fs::read_to_string(&test_unit).unwrap(); + assert_eq!(content, "// my custom test code\n"); +} + +#[test] +fn test_refresh_updates_managed_template_test() { + let tmp = setup_weather_project("wx"); + + // Tamper with managed test_weather.cpp + let test_weather = tmp.path().join("test").join("test_weather.cpp"); + let _original = fs::read_to_string(&test_weather).unwrap(); + fs::write(&test_weather, "// tampered\n").unwrap(); + + // Run refresh --force + commands::refresh::run_refresh( + Some(tmp.path().to_str().unwrap()), + true, + None, + ) + .unwrap(); + + // Managed file should be restored + let content = fs::read_to_string(&test_weather).unwrap(); + assert_ne!(content, "// tampered\n"); + assert!(content.contains("WeatherUnitTest")); +} + +#[test] +fn test_refresh_force_file_overrides_ignore() { + let tmp = setup_weather_project("wx"); + + // Modify ignored test_unit.cpp + let test_unit = tmp.path().join("test").join("test_unit.cpp"); + let _original = fs::read_to_string(&test_unit).unwrap(); + fs::write(&test_unit, "// i want this overwritten\n").unwrap(); + + // Run refresh --force --file test/test_unit.cpp + commands::refresh::run_refresh( + Some(tmp.path().to_str().unwrap()), + true, + Some("test/test_unit.cpp"), + ) + .unwrap(); + + // File should be restored to template version + let content = fs::read_to_string(&test_unit).unwrap(); + assert!( + content.contains("Your unit tests go here"), + "Should be restored to template starter" + ); +} + +#[test] +fn test_refresh_updates_library_driver_headers() { + let tmp = setup_weather_project("wx"); + + // Tamper with a driver header (managed) + let header = tmp + .path() + .join("lib") + .join("drivers") + .join("tmp36") + .join("tmp36.h"); + fs::write(&header, "// tampered\n").unwrap(); + + // Run refresh --force + commands::refresh::run_refresh( + Some(tmp.path().to_str().unwrap()), + true, + None, + ) + .unwrap(); + + // Header should be restored + let content = fs::read_to_string(&header).unwrap(); + assert!(content.contains("TempSensor")); +} + +#[test] +fn test_refresh_freshly_created_project_is_up_to_date() { + let tmp = setup_weather_project("wx"); + + // Refresh without --force should find nothing to do + // (just verifying it doesn't error) + commands::refresh::run_refresh( + Some(tmp.path().to_str().unwrap()), + false, + None, + ) + .unwrap(); +} + +#[test] +fn test_anvilignore_all_files_ascii() { + let tmp = setup_weather_project("wx"); + let content = + fs::read_to_string(tmp.path().join(".anvilignore")).unwrap(); + for (i, byte) in content.bytes().enumerate() { + assert!( + byte < 128, + "Non-ASCII byte {} at offset {} in .anvilignore", + byte, + i + ); + } +} \ No newline at end of file