From a9f729c7dee4afd4ef50896fb6fb423b777de543 Mon Sep 17 00:00:00 2001 From: Eric Ratliff Date: Sat, 21 Feb 2026 21:33:27 -0600 Subject: [PATCH] Down to one test failing --- src/commands/new.rs | 11 + src/commands/refresh.rs | 349 ++- src/ignore.rs | 466 ++++ src/lib.rs | 3 +- src/main.rs | 37 +- src/project/config.rs | 7 + templates/weather/test/test_system.cpp.tmpl | 129 +- templates/weather/test/test_unit.cpp.tmpl | 138 +- templates/weather/test/test_weather.cpp.tmpl | 250 +++ tests/test_refresh.rs | 2121 ++++++++++++++++++ tests/test_template_weather.rs | 415 +++- 11 files changed, 3590 insertions(+), 336 deletions(-) create mode 100644 src/ignore.rs create mode 100644 templates/weather/test/test_weather.cpp.tmpl create mode 100644 tests/test_refresh.rs diff --git a/src/commands/new.rs b/src/commands/new.rs index c1f6409..0fa4284 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -185,6 +185,17 @@ pub fn create_project( 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 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..40200f3 --- /dev/null +++ b/src/ignore.rs @@ -0,0 +1,466 @@ +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"); + content.push_str(&format!("{}/*.ino\n", "*/")); + content.push_str("\n"); + + // Config is protected + content.push_str("# Project configuration.\n"); + content.push_str(".anvil.toml\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/templates/weather/test/test_system.cpp.tmpl b/templates/weather/test/test_system.cpp.tmpl index f5d420e..e5836b9 100644 --- a/templates/weather/test/test_system.cpp.tmpl +++ b/templates/weather/test/test_system.cpp.tmpl @@ -1,3 +1,13 @@ +/* + * 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" @@ -6,114 +16,13 @@ #include "tmp36_sim.h" #include "{{PROJECT_NAME}}_app.h" -// ============================================================================ -// System Tests -- exercise WeatherApp with simulated sensor and hardware +// Example: add your own system tests below +// TEST(MySystemTests, DescribeWhatItTests) { +// mock_arduino_reset(); +// SimHal sim; +// Tmp36Sim sensor(25.0f, 0.5f); // -// These tests use SimHal (simulated GPIO, timing, serial) and Tmp36Sim -// (simulated analog sensor with noise). No mocking expectations -- we -// observe real behavior through SimHal's inspection API. -// ============================================================================ - -class WeatherSystemTest : public ::testing::Test { -protected: - void SetUp() override { - mock_arduino_reset(); - sim_.setMillis(0); - } - - SimHal sim_; - Tmp36Sim sensor_{25.0f}; // 25 C base temperature -}; - -TEST_F(WeatherSystemTest, StartsAndPrintsToSerial) { - WeatherApp app(&sim_, &sensor_); - app.begin(); - - std::string output = sim_.serialOutput(); - EXPECT_NE(output.find("WeatherApp started"), std::string::npos); - EXPECT_NE(output.find("Temperature:"), std::string::npos); -} - -TEST_F(WeatherSystemTest, InitialReadingIsReasonable) { - Tmp36Sim exact_sensor(25.0f, 0.0f); // zero noise - WeatherApp app(&sim_, &exact_sensor); - app.begin(); - - EXPECT_NEAR(app.lastCelsius(), 25.0f, 1.0f); - EXPECT_EQ(app.readCount(), 1); -} - -TEST_F(WeatherSystemTest, ReadsAtTwoSecondIntervals) { - WeatherApp app(&sim_, &sensor_); - app.begin(); - EXPECT_EQ(app.readCount(), 1); - - // 1 second -- no new reading - sim_.advanceMillis(1000); - app.update(); - EXPECT_EQ(app.readCount(), 1); - - // 2 seconds -- new reading - sim_.advanceMillis(1000); - app.update(); - EXPECT_EQ(app.readCount(), 2); - - // 4 seconds -- another reading - sim_.advanceMillis(2000); - app.update(); - EXPECT_EQ(app.readCount(), 3); -} - -TEST_F(WeatherSystemTest, FiveMinuteRun) { - WeatherApp app(&sim_, &sensor_); - app.begin(); - - // Run 5 minutes at 100ms resolution - for (int i = 0; i < 3000; ++i) { - sim_.advanceMillis(100); - app.update(); - } - - // 5 minutes = 300 seconds / 2 second interval = 150 readings + 1 initial - EXPECT_EQ(app.readCount(), 151); -} - -TEST_F(WeatherSystemTest, TemperatureChangeMidRun) { - Tmp36Sim sensor(20.0f, 0.0f); // start at 20 C, no noise - WeatherApp app(&sim_, &sensor); - app.begin(); - - EXPECT_NEAR(app.lastCelsius(), 20.0f, 1.0f); - - // Change temperature and wait for next reading - sensor.setBaseTemperature(35.0f); - sim_.advanceMillis(2000); - app.update(); - - EXPECT_NEAR(app.lastCelsius(), 35.0f, 1.0f); -} - -TEST_F(WeatherSystemTest, SerialOutputContainsFahrenheit) { - Tmp36Sim exact_sensor(0.0f, 0.0f); // 0 C = 32 F - WeatherApp app(&sim_, &exact_sensor); - app.begin(); - - std::string output = sim_.serialOutput(); - EXPECT_NE(output.find("32"), std::string::npos) - << "Should contain 32 F for 0 C: " << output; -} - -TEST_F(WeatherSystemTest, NoisyReadingsStayInRange) { - Tmp36Sim noisy_sensor(25.0f, 2.0f); // +/- 2 C noise - noisy_sensor.setSeed(42); - WeatherApp app(&sim_, &noisy_sensor); - - for (int i = 0; i < 20; ++i) { - sim_.setMillis(i * 2000); - if (i == 0) app.begin(); else app.update(); - - float c = app.lastCelsius(); - EXPECT_GE(c, 20.0f) << "Reading " << i << " too low: " << c; - EXPECT_LE(c, 30.0f) << "Reading " << i << " too high: " << c; - } -} +// 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 index 30a7b98..6ba9ac1 100644 --- a/templates/weather/test/test_unit.cpp.tmpl +++ b/templates/weather/test/test_unit.cpp.tmpl @@ -1,3 +1,13 @@ +/* + * 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 @@ -9,124 +19,12 @@ 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); -} +// 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 index 5c05213..12e17ee 100644 --- a/tests/test_template_weather.rs +++ b/tests/test_template_weather.rs @@ -1,4 +1,5 @@ use anvil::commands; +use anvil::ignore::{self, AnvilIgnore}; use anvil::library; use anvil::project::config::ProjectConfig; use anvil::templates::{TemplateManager, TemplateContext}; @@ -174,25 +175,38 @@ fn test_weather_sketch_replaces_basic_sketch() { } #[test] -fn test_weather_has_unit_tests() { +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(); - assert!(content.contains("Tmp36Mock")); - assert!(content.contains("WeatherUnitTest")); - assert!(content.contains("wx_app.h")); + // 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_system_tests() { +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("Tmp36Sim")); - assert!(content.contains("WeatherSystemTest")); - assert!(content.contains("SimHal")); + assert!(content.contains("Your system tests go here")); + assert!(!content.contains("WeatherSystemTest")); } #[test] @@ -425,11 +439,12 @@ fn test_weather_full_flow() { assert!(!app_content.contains("BlinkApp")); // Test files present (weather-specific) - let unit_content = fs::read_to_string( - tmp.path().join("test").join("test_unit.cpp"), + let weather_content = fs::read_to_string( + tmp.path().join("test").join("test_weather.cpp"), ) .unwrap(); - assert!(unit_content.contains("Tmp36Mock")); + assert!(weather_content.contains("Tmp36Mock")); + assert!(weather_content.contains("Tmp36Sim")); // Sketch wires everything together let ino_content = fs::read_to_string( @@ -596,7 +611,7 @@ fn test_template_system_tests_use_valid_sim_api() { .unwrap(); let test_source = fs::read_to_string( - tmp.path().join("test").join("test_system.cpp"), + tmp.path().join("test").join("test_weather.cpp"), ) .unwrap(); @@ -624,7 +639,7 @@ fn test_template_system_tests_use_valid_sim_api() { if before_dot.contains("sensor") || before_dot.contains("sim") { assert!( methods.contains(&method_name.to_string()), - "test_system.cpp calls '{}.{}()' but '{}' \ + "test_weather.cpp calls '{}.{}()' but '{}' \ is not in tmp36_sim.h.\n \ Available methods: {:?}", before_dot, @@ -653,7 +668,7 @@ fn test_template_unit_tests_use_valid_mock_api() { .unwrap(); let test_source = fs::read_to_string( - tmp.path().join("test").join("test_unit.cpp"), + tmp.path().join("test").join("test_weather.cpp"), ) .unwrap(); @@ -676,7 +691,7 @@ fn test_template_unit_tests_use_valid_mock_api() { if before_dot.contains("sensor") { assert!( methods.contains(&method_name.to_string()), - "test_unit.cpp calls '{}.{}()' but '{}' \ + "test_weather.cpp calls '{}.{}()' but '{}' \ is not in tmp36_mock.h.\n \ Available methods: {:?}", before_dot, @@ -708,7 +723,7 @@ fn test_template_sim_constructor_arg_count() { count_constructor_params(&sim_header, "Tmp36Sim"); let test_source = fs::read_to_string( - tmp.path().join("test").join("test_system.cpp"), + tmp.path().join("test").join("test_weather.cpp"), ) .unwrap(); @@ -735,7 +750,7 @@ fn test_template_sim_constructor_arg_count() { }; assert!( arg_count >= min_args && arg_count <= total_params, - "test_system.cpp line {}: Tmp36Sim() called with \ + "test_weather.cpp line {}: Tmp36Sim() called with \ {} args, but constructor accepts {}-{} args.\n \ Line: {}", line_num + 1, @@ -747,4 +762,370 @@ fn test_template_sim_constructor_arg_count() { } } } +} + +// ========================================================================= +// .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