diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 0501216..9369ab2 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -3,7 +3,9 @@ use std::fs; use std::path::Path; use anvil::templates::{TemplateManager, TemplateContext}; -use anvil::project::config::ProjectConfig; +use anvil::project::config::{ + ProjectConfig, BoardProfile, CONFIG_FILENAME, set_default_in_file, +}; // ============================================================================ // Template extraction tests @@ -332,6 +334,467 @@ fn test_config_skips_nonexistent_include_dirs() { assert_eq!(flags.len(), 0, "Should skip non-existent directories"); } +// ============================================================================ +// Config: default board and [boards.X] structure +// ============================================================================ + +#[test] +fn test_config_default_points_to_boards_section() { + let config = ProjectConfig::new("test"); + assert_eq!(config.build.default, "uno"); + assert!( + config.boards.contains_key("uno"), + "boards map should contain the default board" + ); + assert_eq!(config.boards["uno"].fqbn, "arduino:avr:uno"); +} + +#[test] +fn test_config_default_fqbn_resolves() { + let config = ProjectConfig::new("test"); + let fqbn = config.default_fqbn().unwrap(); + assert_eq!(fqbn, "arduino:avr:uno"); +} + +#[test] +fn test_config_default_board_returns_fqbn_and_baud() { + let config = ProjectConfig::new("test"); + let (fqbn, baud) = config.default_board().unwrap(); + assert_eq!(fqbn, "arduino:avr:uno"); + assert_eq!(baud, 115200); +} + +#[test] +fn test_config_resolve_board_named() { + let mut config = ProjectConfig::new("test"); + config.boards.insert("mega".to_string(), BoardProfile { + fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(), + baud: Some(57600), + }); + let (fqbn, baud) = config.resolve_board("mega").unwrap(); + assert_eq!(fqbn, "arduino:avr:mega:cpu=atmega2560"); + assert_eq!(baud, 57600); +} + +#[test] +fn test_config_resolve_board_inherits_baud() { + let mut config = ProjectConfig::new("test"); + config.boards.insert("nano".to_string(), BoardProfile { + fqbn: "arduino:avr:nano".to_string(), + baud: None, + }); + let (_, baud) = config.resolve_board("nano").unwrap(); + assert_eq!( + baud, config.monitor.baud, + "Should inherit monitor baud when board has no override" + ); +} + +#[test] +fn test_config_resolve_board_unknown_fails() { + let config = ProjectConfig::new("test"); + assert!(config.resolve_board("esp32").is_err()); +} + +#[test] +fn test_config_new_with_board_preset() { + let config = ProjectConfig::new_with_board( + "test", "mega", "arduino:avr:mega:cpu=atmega2560", 115200, + ); + assert_eq!(config.build.default, "mega"); + assert!(config.boards.contains_key("mega")); + assert_eq!( + config.boards["mega"].fqbn, + "arduino:avr:mega:cpu=atmega2560" + ); + assert!( + !config.boards.contains_key("uno"), + "Should only contain the specified board" + ); +} + +#[test] +fn test_config_board_roundtrip() { + let tmp = TempDir::new().unwrap(); + let mut config = ProjectConfig::new("multi"); + config.boards.insert("mega".to_string(), BoardProfile { + fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(), + baud: Some(57600), + }); + config.save(tmp.path()).unwrap(); + + let loaded = ProjectConfig::load(tmp.path()).unwrap(); + assert!(loaded.boards.contains_key("mega")); + let mega = &loaded.boards["mega"]; + assert_eq!(mega.fqbn, "arduino:avr:mega:cpu=atmega2560"); + assert_eq!(mega.baud, Some(57600)); +} + +#[test] +fn test_toml_template_has_default_field() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "default_test".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 content = fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap(); + assert!( + content.contains("default = \"uno\""), + ".anvil.toml should have default = \"uno\" in [build]" + ); + assert!( + content.contains("[boards.uno]"), + ".anvil.toml should have a [boards.uno] section" + ); +} + +#[test] +fn test_toml_template_no_fqbn_in_build_section() { + // New configs should NOT have fqbn directly in [build] + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "no_build_fqbn".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 content = fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap(); + + let mut in_build = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed == "[build]" { + in_build = true; + continue; + } + if trimmed.starts_with('[') { + in_build = false; + } + if in_build && !trimmed.starts_with('#') { + assert!( + !trimmed.starts_with("fqbn"), + "[build] section should not contain fqbn; it belongs in [boards.X]" + ); + } + } +} + +// ============================================================================ +// Config migration: old format -> new format +// ============================================================================ + +#[test] +fn test_migrate_old_config_adds_default_and_boards() { + let tmp = TempDir::new().unwrap(); + let old_config = "\ +[project]\n\ +name = \"hand\"\n\ +anvil_version = \"1.0.0\"\n\ +\n\ +[build]\n\ +fqbn = \"arduino:avr:uno\"\n\ +warnings = \"more\"\n\ +include_dirs = [\"lib/hal\", \"lib/app\"]\n\ +extra_flags = [\"-Werror\"]\n\ +\n\ +[monitor]\n\ +baud = 115200\n\ +\n\ +[boards.micro]\n\ +fqbn = \"arduino:avr:micro\"\n"; + + fs::write(tmp.path().join(CONFIG_FILENAME), old_config).unwrap(); + + let config = ProjectConfig::load(tmp.path()).unwrap(); + + // In-memory state + assert_eq!(config.build.default, "uno"); + assert!(config.boards.contains_key("uno")); + assert_eq!(config.boards["uno"].fqbn, "arduino:avr:uno"); + assert!(config.boards.contains_key("micro")); + + // On-disk file rewritten + let content = fs::read_to_string(tmp.path().join(CONFIG_FILENAME)).unwrap(); + assert!( + content.contains("default = \"uno\""), + "Migrated file should contain default = \"uno\"" + ); + assert!( + content.contains("[boards.uno]"), + "Migrated file should add [boards.uno] section" + ); + // Old fqbn kept for backward compat with pre-refresh scripts + assert!( + content.contains("fqbn = \"arduino:avr:uno\""), + "Migration should keep old fqbn for backward compat" + ); +} + +#[test] +fn test_migrate_preserves_comments() { + let tmp = TempDir::new().unwrap(); + let old_config = "\ +[project]\n\ +name = \"test\"\n\ +anvil_version = \"1.0.0\"\n\ +\n\ +[build]\n\ +fqbn = \"arduino:avr:uno\"\n\ +warnings = \"more\"\n\ +include_dirs = [\"lib/hal\", \"lib/app\"]\n\ +extra_flags = [\"-Werror\"]\n\ +\n\ +[monitor]\n\ +baud = 115200\n\ +# port = \"/dev/ttyUSB0\" # Uncomment to skip auto-detect\n"; + + fs::write(tmp.path().join(CONFIG_FILENAME), old_config).unwrap(); + ProjectConfig::load(tmp.path()).unwrap(); + + let content = fs::read_to_string(tmp.path().join(CONFIG_FILENAME)).unwrap(); + assert!( + content.contains("# port = \"/dev/ttyUSB0\""), + "Migration should preserve comments" + ); +} + +#[test] +fn test_migrate_matches_preset_name() { + // A mega FQBN should get board name "mega", not "default" + let tmp = TempDir::new().unwrap(); + let old_config = "\ +[project]\n\ +name = \"test\"\n\ +anvil_version = \"1.0.0\"\n\ +\n\ +[build]\n\ +fqbn = \"arduino:avr:mega:cpu=atmega2560\"\n\ +warnings = \"more\"\n\ +include_dirs = [\"lib/hal\", \"lib/app\"]\n\ +extra_flags = [\"-Werror\"]\n\ +\n\ +[monitor]\n\ +baud = 115200\n"; + + fs::write(tmp.path().join(CONFIG_FILENAME), old_config).unwrap(); + + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(config.build.default, "mega"); + assert!(config.boards.contains_key("mega")); +} + +#[test] +fn test_migrate_unknown_fqbn_uses_default_name() { + let tmp = TempDir::new().unwrap(); + let old_config = "\ +[project]\n\ +name = \"test\"\n\ +anvil_version = \"1.0.0\"\n\ +\n\ +[build]\n\ +fqbn = \"some:custom:board\"\n\ +warnings = \"more\"\n\ +include_dirs = [\"lib/hal\", \"lib/app\"]\n\ +extra_flags = [\"-Werror\"]\n\ +\n\ +[monitor]\n\ +baud = 115200\n"; + + fs::write(tmp.path().join(CONFIG_FILENAME), old_config).unwrap(); + + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(config.build.default, "default"); + assert!(config.boards.contains_key("default")); + assert_eq!(config.boards["default"].fqbn, "some:custom:board"); +} + +#[test] +fn test_migrate_skips_if_already_has_default() { + let tmp = TempDir::new().unwrap(); + let new_config = "\ +[project]\n\ +name = \"test\"\n\ +anvil_version = \"1.0.0\"\n\ +\n\ +[build]\n\ +default = \"uno\"\n\ +warnings = \"more\"\n\ +include_dirs = [\"lib/hal\", \"lib/app\"]\n\ +extra_flags = [\"-Werror\"]\n\ +\n\ +[monitor]\n\ +baud = 115200\n\ +\n\ +[boards.uno]\n\ +fqbn = \"arduino:avr:uno\"\n"; + + fs::write(tmp.path().join(CONFIG_FILENAME), new_config).unwrap(); + + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(config.build.default, "uno"); + + // File should be unchanged + let content = fs::read_to_string(tmp.path().join(CONFIG_FILENAME)).unwrap(); + assert_eq!( + content, new_config, + "New-format config should not be modified" + ); +} + +#[test] +fn test_migrate_does_not_duplicate_board_section() { + // If [boards.uno] already exists, migration should not add a second one + let tmp = TempDir::new().unwrap(); + let old_config = "\ +[project]\n\ +name = \"test\"\n\ +anvil_version = \"1.0.0\"\n\ +\n\ +[build]\n\ +fqbn = \"arduino:avr:uno\"\n\ +warnings = \"more\"\n\ +include_dirs = [\"lib/hal\", \"lib/app\"]\n\ +extra_flags = [\"-Werror\"]\n\ +\n\ +[monitor]\n\ +baud = 115200\n\ +\n\ +[boards.uno]\n\ +fqbn = \"arduino:avr:uno\"\n"; + + fs::write(tmp.path().join(CONFIG_FILENAME), old_config).unwrap(); + ProjectConfig::load(tmp.path()).unwrap(); + + let content = fs::read_to_string(tmp.path().join(CONFIG_FILENAME)).unwrap(); + let count = content.matches("[boards.uno]").count(); + assert_eq!(count, 1, "Should not duplicate [boards.uno] section"); +} + +#[test] +fn test_migrate_default_appears_before_fqbn() { + // After migration, default = "..." should come before fqbn = "..." + // in [build] for natural reading order. + let tmp = TempDir::new().unwrap(); + let old_config = "\ +[project]\n\ +name = \"test\"\n\ +anvil_version = \"1.0.0\"\n\ +\n\ +[build]\n\ +fqbn = \"arduino:avr:uno\"\n\ +warnings = \"more\"\n\ +include_dirs = [\"lib/hal\", \"lib/app\"]\n\ +extra_flags = [\"-Werror\"]\n\ +\n\ +[monitor]\n\ +baud = 115200\n"; + + fs::write(tmp.path().join(CONFIG_FILENAME), old_config).unwrap(); + ProjectConfig::load(tmp.path()).unwrap(); + + let content = fs::read_to_string(tmp.path().join(CONFIG_FILENAME)).unwrap(); + let default_pos = content.find("default = ").expect("should have default"); + let fqbn_pos = content.find("fqbn = ").expect("should have fqbn"); + assert!( + default_pos < fqbn_pos, + "default should appear before fqbn in [build] section" + ); +} + +// ============================================================================ +// set_default_in_file: text-based config editing +// ============================================================================ + +#[test] +fn test_set_default_replaces_existing() { + let tmp = TempDir::new().unwrap(); + let config = ProjectConfig::new("test"); + config.save(tmp.path()).unwrap(); + + let config_path = tmp.path().join(CONFIG_FILENAME); + let old = set_default_in_file(&config_path, "mega").unwrap(); + assert_eq!(old, "uno"); + + let content = fs::read_to_string(&config_path).unwrap(); + assert!(content.contains("default = \"mega\"")); + assert!(!content.contains("default = \"uno\"")); +} + +#[test] +fn test_set_default_adds_when_missing() { + let tmp = TempDir::new().unwrap(); + let content = "\ +[project]\n\ +name = \"test\"\n\ +anvil_version = \"1.0.0\"\n\ +\n\ +[build]\n\ +warnings = \"more\"\n\ +include_dirs = [\"lib/hal\", \"lib/app\"]\n\ +extra_flags = [\"-Werror\"]\n\ +\n\ +[monitor]\n\ +baud = 115200\n\ +\n\ +[boards.uno]\n\ +fqbn = \"arduino:avr:uno\"\n"; + + let config_path = tmp.path().join(CONFIG_FILENAME); + fs::write(&config_path, content).unwrap(); + + let old = set_default_in_file(&config_path, "uno").unwrap(); + assert_eq!(old, "", "Should return empty string when no previous default"); + + let result = fs::read_to_string(&config_path).unwrap(); + assert!(result.contains("default = \"uno\"")); +} + +#[test] +fn test_set_default_is_idempotent() { + let tmp = TempDir::new().unwrap(); + let config = ProjectConfig::new("test"); + config.save(tmp.path()).unwrap(); + + let config_path = tmp.path().join(CONFIG_FILENAME); + + set_default_in_file(&config_path, "mega").unwrap(); + let content_after_first = fs::read_to_string(&config_path).unwrap(); + + set_default_in_file(&config_path, "mega").unwrap(); + let content_after_second = fs::read_to_string(&config_path).unwrap(); + + assert_eq!( + content_after_first, content_after_second, + "Second set should not change file" + ); +} + +#[test] +fn test_set_default_preserves_other_fields() { + let tmp = TempDir::new().unwrap(); + let config = ProjectConfig::new("test"); + config.save(tmp.path()).unwrap(); + + let config_path = tmp.path().join(CONFIG_FILENAME); + set_default_in_file(&config_path, "mega").unwrap(); + + // Reload and check nothing else broke + let loaded = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(loaded.build.default, "mega"); + assert_eq!(loaded.build.warnings, "more"); + assert_eq!(loaded.project.name, "test"); + assert_eq!(loaded.monitor.baud, 115200); + assert!(loaded.build.include_dirs.contains(&"lib/hal".to_string())); +} + // ============================================================================ // Full project creation test (end-to-end in temp dir) // ============================================================================ @@ -1074,7 +1537,15 @@ fn test_board_preset_fqbn_in_config() { let config = ProjectConfig::load(tmp.path()).unwrap(); assert_eq!( config.build.default, "mega", - ".anvil.toml should contain mega FQBN" + ".anvil.toml should have default = mega" + ); + assert!( + config.boards.contains_key("mega"), + ".anvil.toml should have [boards.mega]" + ); + assert_eq!( + config.boards["mega"].fqbn, "arduino:avr:mega:cpu=atmega2560", + "[boards.mega] should have the correct FQBN" ); } @@ -1194,4 +1665,204 @@ fn test_toml_template_has_board_profile_comments() { content.contains("[boards.mega]") || content.contains("boards.mega"), ".anvil.toml should show board profile examples in comments" ); +} + +// ========================================================================== +// Scripts read default board from [build] section +// ========================================================================== + +#[test] +fn test_scripts_read_default_board() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "default_read".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(); + + for script in &["build.sh", "upload.sh", "monitor.sh"] { + let content = fs::read_to_string(tmp.path().join(script)).unwrap(); + assert!( + content.contains("DEFAULT_BOARD") && content.contains("'default'"), + "{} should read 'default' field from .anvil.toml into DEFAULT_BOARD", + script + ); + assert!( + content.contains("ACTIVE_BOARD"), + "{} should resolve ACTIVE_BOARD from DEFAULT_BOARD or --board flag", + script + ); + } + + for bat in &["build.bat", "upload.bat", "monitor.bat"] { + let content = fs::read_to_string(tmp.path().join(bat)).unwrap(); + assert!( + content.contains("DEFAULT_BOARD"), + "{} should read default field into DEFAULT_BOARD", + bat + ); + } +} + +// ========================================================================== +// USB_VID/USB_PID fix: compiler.cpp.extra_flags (not build.extra_flags) +// ========================================================================== + +#[test] +fn test_scripts_use_compiler_extra_flags_not_build() { + // Regression: build.extra_flags clobbers board defaults (USB_VID, USB_PID) + // on ATmega32U4 boards (Micro, Leonardo). Scripts must use + // compiler.cpp.extra_flags and compiler.c.extra_flags instead, + // which are additive. + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "usb_fix".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 compile_scripts = vec![ + "build.sh", "build.bat", + "upload.sh", "upload.bat", + ]; + + for script in &compile_scripts { + let content = fs::read_to_string(tmp.path().join(script)).unwrap(); + assert!( + !content.contains("build.extra_flags"), + "{} must NOT use build.extra_flags (clobbers USB_VID/USB_PID on ATmega32U4)", + script + ); + assert!( + content.contains("compiler.cpp.extra_flags"), + "{} should use compiler.cpp.extra_flags (additive, USB-safe)", + script + ); + assert!( + content.contains("compiler.c.extra_flags"), + "{} should use compiler.c.extra_flags (additive, USB-safe)", + script + ); + } +} + +#[test] +fn test_monitor_scripts_have_no_compile_flags() { + // monitor.sh and monitor.bat should NOT contain any compile flags + // since they don't compile anything. + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "mon_flags".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(); + + for script in &["monitor.sh", "monitor.bat"] { + let content = fs::read_to_string(tmp.path().join(script)).unwrap(); + assert!( + !content.contains("compiler.cpp.extra_flags") + && !content.contains("compiler.c.extra_flags") + && !content.contains("build.extra_flags"), + "{} should not contain any compile flags", + script + ); + } +} + +// ========================================================================== +// Script error messages: helpful guidance without requiring anvil +// ========================================================================== + +#[test] +fn test_script_errors_show_manual_fix() { + // Error messages should explain how to fix .anvil.toml by hand, + // since the project is self-contained and anvil may not be installed. + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "err_msg".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 all_scripts = vec![ + "build.sh", "build.bat", + "upload.sh", "upload.bat", + "monitor.sh", "monitor.bat", + ]; + + for script in &all_scripts { + let content = fs::read_to_string(tmp.path().join(script)).unwrap(); + assert!( + content.contains("default = "), + "{} error messages should show the manual fix (default = \"...\")", + script + ); + } +} + +#[test] +fn test_script_errors_mention_arduino_cli() { + // Error messages should mention arduino-cli board listall as a + // discovery option, since the project doesn't require anvil. + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "cli_err".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 all_scripts = vec![ + "build.sh", "build.bat", + "upload.sh", "upload.bat", + "monitor.sh", "monitor.bat", + ]; + + for script in &all_scripts { + let content = fs::read_to_string(tmp.path().join(script)).unwrap(); + assert!( + content.contains("arduino-cli board listall"), + "{} error messages should mention 'arduino-cli board listall' for board discovery", + script + ); + } +} + +#[test] +fn test_script_errors_mention_toml_section_syntax() { + // The "board not found" error in build/upload scripts should show + // the [boards.X] section syntax so users know exactly what to add. + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "section_err".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(); + + // build and upload scripts have both no-default and board-not-found errors + for script in &["build.sh", "build.bat", "upload.sh", "upload.bat"] { + let content = fs::read_to_string(tmp.path().join(script)).unwrap(); + assert!( + content.contains("[boards."), + "{} board-not-found error should show [boards.X] section syntax", + script + ); + } } \ No newline at end of file