use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::fs; use anyhow::{Result, Context, bail}; use crate::version::ANVIL_VERSION; pub const CONFIG_FILENAME: &str = ".anvil.toml"; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ProjectConfig { pub project: ProjectMeta, pub build: BuildConfig, pub monitor: MonitorConfig, #[serde(default)] pub boards: HashMap, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub pins: HashMap, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ProjectMeta { pub name: String, pub anvil_version: String, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct BuildConfig { /// Name of the default board from [boards.*] #[serde(default)] pub default: String, pub warnings: String, pub include_dirs: Vec, pub extra_flags: Vec, /// Legacy: FQBN used to live here directly. Now lives in [boards.*]. #[serde(default, skip_serializing_if = "Option::is_none")] pub fqbn: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct MonitorConfig { pub baud: u32, #[serde(default, skip_serializing_if = "Option::is_none")] pub port: Option, } /// A named board with its FQBN and optional baud override. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct BoardProfile { pub fqbn: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub baud: Option, } /// Pin configuration for a specific board. #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct BoardPinConfig { /// Individual pin assignments: name -> { pin, mode } #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub assignments: HashMap, /// Bus group reservations: "spi" -> { cs = 10 }, "i2c" -> {} #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub buses: HashMap, } /// A single pin assignment with a friendly name. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct PinAssignment { pub pin: u8, pub mode: String, } /// Configuration for a claimed bus group. /// User-selectable pins (like CS for SPI) are stored as flattened keys. #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct BusConfig { #[serde(flatten)] pub user_pins: HashMap, } impl ProjectConfig { /// Create a new project config with sensible defaults. pub fn new(name: &str) -> Self { let mut boards = HashMap::new(); boards.insert("uno".to_string(), BoardProfile { fqbn: "arduino:avr:uno".to_string(), baud: None, }); Self { project: ProjectMeta { name: name.to_string(), anvil_version: ANVIL_VERSION.to_string(), }, build: BuildConfig { default: "uno".to_string(), fqbn: None, warnings: "more".to_string(), include_dirs: vec!["lib/hal".to_string(), "lib/app".to_string()], extra_flags: vec!["-Werror".to_string()], }, monitor: MonitorConfig { baud: 115200, port: None, }, boards, pins: HashMap::new(), } } /// Create a new project config with a specific board preset. pub fn new_with_board(name: &str, board_name: &str, fqbn: &str, baud: u32) -> Self { let mut config = Self::new(name); config.build.default = board_name.to_string(); config.boards.clear(); config.boards.insert(board_name.to_string(), BoardProfile { fqbn: fqbn.to_string(), baud: None, }); config.monitor.baud = baud; config } /// Get the default board's FQBN and baud. pub fn default_board(&self) -> Result<(String, u32)> { self.resolve_board(&self.build.default) } /// Get the default board's FQBN. pub fn default_fqbn(&self) -> Result { let (fqbn, _) = self.default_board()?; Ok(fqbn) } /// Resolve the FQBN and baud for a named board. /// Returns (fqbn, baud). pub fn resolve_board(&self, board_name: &str) -> Result<(String, u32)> { match self.boards.get(board_name) { Some(profile) => { let baud = profile.baud.unwrap_or(self.monitor.baud); Ok((profile.fqbn.clone(), baud)) } None => { let available: Vec<&String> = self.boards.keys().collect(); if available.is_empty() { bail!( "No board '{}' found in .anvil.toml.\n \ No boards are defined. Add one: anvil board --add {}", board_name, board_name ); } else { bail!( "No board '{}' found in .anvil.toml.\n \ Available: {}\n \ Add it: anvil board --add {}", board_name, available.iter().map(|s| s.as_str()).collect::>().join(", "), board_name ); } } } } /// Load config from a project directory. pub fn load(project_root: &Path) -> Result { let config_path = project_root.join(CONFIG_FILENAME); if !config_path.exists() { bail!( "Not an Anvil project (missing {}).\n\ Create one with: anvil new ", CONFIG_FILENAME ); } let contents = fs::read_to_string(&config_path) .context(format!("Failed to read {}", config_path.display()))?; let mut config: ProjectConfig = toml::from_str(&contents) .context(format!("Failed to parse {}", config_path.display()))?; // Migrate old format: fqbn in [build] -> [boards.X] + default if config.build.default.is_empty() { if let Some(ref legacy_fqbn) = config.build.fqbn { let board_name = crate::board::presets::PRESETS.iter() .find(|p| p.fqbn == *legacy_fqbn) .map(|p| p.name.to_string()) .unwrap_or_else(|| "default".to_string()); // Text-based migration of the actual file migrate_config_file(&config_path, &board_name, legacy_fqbn)?; // Update in-memory config to match let fqbn_clone = legacy_fqbn.clone(); config.build.fqbn = None; config.build.default = board_name.clone(); config.boards.entry(board_name).or_insert(BoardProfile { fqbn: fqbn_clone, baud: None, }); } } Ok(config) } /// Save config to a project directory. pub fn save(&self, project_root: &Path) -> Result<()> { let config_path = project_root.join(CONFIG_FILENAME); let contents = toml::to_string_pretty(self) .context("Failed to serialize config")?; fs::write(&config_path, contents) .context(format!("Failed to write {}", config_path.display()))?; Ok(()) } /// Walk up from a directory to find the project root containing .anvil.toml. pub fn find_project_root(start: &Path) -> Result { let mut dir = if start.is_absolute() { start.to_path_buf() } else { std::env::current_dir()?.join(start) }; for _ in 0..10 { if dir.join(CONFIG_FILENAME).exists() { return Ok(dir); } match dir.parent() { Some(parent) => dir = parent.to_path_buf(), None => break, } } bail!( "No {} found in {} or any parent directory.\n\ Create a project with: anvil new ", CONFIG_FILENAME, start.display() ); } /// Resolve include directories to absolute paths relative to project root. pub fn resolve_include_flags(&self, project_root: &Path) -> Vec { let mut flags = Vec::new(); for dir in &self.build.include_dirs { let abs = project_root.join(dir); if abs.is_dir() { flags.push(format!("-I{}", abs.display())); } } flags } /// Build the full extra_flags string for arduino-cli. pub fn extra_flags_string(&self, project_root: &Path) -> String { let mut parts = self.resolve_include_flags(project_root); for flag in &self.build.extra_flags { parts.push(flag.clone()); } parts.join(" ") } } impl Default for ProjectConfig { fn default() -> Self { Self::new("untitled") } } /// Return the Anvil home directory (~/.anvil). pub fn anvil_home() -> Result { let home = dirs::home_dir() .context("Could not determine home directory")?; let anvil_dir = home.join(".anvil"); fs::create_dir_all(&anvil_dir)?; Ok(anvil_dir) } // -- Text-based .anvil.toml editing ----------------------------------------- // These operate on the raw file text to preserve comments and formatting. /// Migrate an old-format config: add default = "X" to [build] and add /// [boards.X] section if needed. Keeps old fqbn line for backward /// compatibility with scripts that haven't been refreshed yet. fn migrate_config_file(config_path: &Path, board_name: &str, fqbn: &str) -> Result<()> { let content = fs::read_to_string(config_path) .context("Failed to read .anvil.toml for migration")?; let mut output = String::new(); let mut in_build = false; let mut added_default = false; let mut has_board_section = false; // Check if [boards.NAME] already exists let section_header = format!("[boards.{}]", board_name); for line in content.lines() { if line.trim() == section_header { has_board_section = true; break; } } for line in content.lines() { let trimmed = line.trim(); // Track which section we're in if trimmed == "[build]" { in_build = true; output.push_str(line); output.push('\n'); continue; } if trimmed.starts_with('[') && trimmed != "[build]" { // Leaving [build] -- add default before leaving if we haven't yet if in_build && !added_default { output.push_str(&format!("default = \"{}\"\n", board_name)); added_default = true; } in_build = false; } // In [build]: add default right before fqbn (natural reading order) if in_build && !added_default && trimmed.starts_with("fqbn") && trimmed.contains('=') { output.push_str(&format!("default = \"{}\"\n", board_name)); added_default = true; } output.push_str(line); output.push('\n'); } // Add [boards.NAME] section if it doesn't already exist if !has_board_section { while output.ends_with("\n\n") { output.pop(); } output.push('\n'); output.push_str(&format!("\n[boards.{}]\n", board_name)); output.push_str(&format!("fqbn = \"{}\"\n", fqbn)); } fs::write(config_path, &output) .context("Failed to write migrated .anvil.toml")?; eprintln!( "\x1b[33minfo\x1b[0m Migrated .anvil.toml: default = \"{}\"{}", board_name, if has_board_section { "" } else { ", added [boards] section" } ); eprintln!( "\x1b[33minfo\x1b[0m Run \x1b[36manvil refresh --force\x1b[0m to update scripts." ); Ok(()) } /// Set or change the default board in .anvil.toml (text-based edit). /// Returns the old default name (empty string if there wasn't one). pub fn set_default_in_file(config_path: &Path, board_name: &str) -> Result { let content = fs::read_to_string(config_path) .context("Failed to read .anvil.toml")?; let mut output = String::new(); let mut in_build = false; let mut replaced = false; let mut old_default = String::new(); for line in content.lines() { let trimmed = line.trim(); if trimmed == "[build]" { in_build = true; output.push_str(line); output.push('\n'); continue; } if trimmed.starts_with('[') && trimmed != "[build]" { // Leaving [build] without having found default -- add it if in_build && !replaced { output.push_str(&format!("default = \"{}\"\n", board_name)); replaced = true; } in_build = false; } if in_build && trimmed.starts_with("default") && trimmed.contains('=') { // Extract old value if let Some(val) = trimmed.split('=').nth(1) { old_default = val.trim().trim_matches('"').to_string(); } output.push_str(&format!("default = \"{}\"\n", board_name)); replaced = true; continue; } output.push_str(line); output.push('\n'); } // Edge case: [build] was the last section and had no default if in_build && !replaced { output.push_str(&format!("default = \"{}\"\n", board_name)); } fs::write(config_path, &output) .context("Failed to write .anvil.toml")?; Ok(old_default) } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn test_new_config_defaults() { let config = ProjectConfig::new("test_project"); assert_eq!(config.project.name, "test_project"); assert_eq!(config.build.default, "uno"); assert_eq!(config.monitor.baud, 115200); assert!(config.build.include_dirs.contains(&"lib/hal".to_string())); assert!(config.boards.contains_key("uno")); assert_eq!(config.boards["uno"].fqbn, "arduino:avr:uno"); } #[test] fn test_save_and_load() { let tmp = TempDir::new().unwrap(); let config = ProjectConfig::new("roundtrip"); config.save(tmp.path()).unwrap(); let loaded = ProjectConfig::load(tmp.path()).unwrap(); assert_eq!(loaded.project.name, "roundtrip"); assert_eq!(loaded.build.default, "uno"); assert_eq!(loaded.monitor.baud, config.monitor.baud); assert!(loaded.boards.contains_key("uno")); } #[test] fn test_new_with_board() { 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")); } #[test] fn test_find_project_root() { let tmp = TempDir::new().unwrap(); let config = ProjectConfig::new("finder"); config.save(tmp.path()).unwrap(); let sub = tmp.path().join("sketch").join("deep"); fs::create_dir_all(&sub).unwrap(); let found = ProjectConfig::find_project_root(&sub).unwrap(); assert_eq!(found, tmp.path()); } #[test] fn test_find_project_root_not_found() { let tmp = TempDir::new().unwrap(); let result = ProjectConfig::find_project_root(tmp.path()); assert!(result.is_err()); } #[test] fn test_resolve_include_flags() { let tmp = TempDir::new().unwrap(); fs::create_dir_all(tmp.path().join("lib/hal")).unwrap(); fs::create_dir_all(tmp.path().join("lib/app")).unwrap(); let config = ProjectConfig::new("includes"); let flags = config.resolve_include_flags(tmp.path()); assert_eq!(flags.len(), 2); assert!(flags[0].starts_with("-I")); assert!(flags[0].contains("lib")); } #[test] fn test_extra_flags_string() { let tmp = TempDir::new().unwrap(); fs::create_dir_all(tmp.path().join("lib/hal")).unwrap(); fs::create_dir_all(tmp.path().join("lib/app")).unwrap(); let config = ProjectConfig::new("flags"); let flags = config.extra_flags_string(tmp.path()); assert!(flags.contains("-Werror")); assert!(flags.contains("-I")); } #[test] fn test_default_board() { 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_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(9600), }); let (fqbn, baud) = config.resolve_board("mega").unwrap(); assert_eq!(fqbn, "arduino:avr:mega:cpu=atmega2560"); assert_eq!(baud, 9600); } #[test] fn test_resolve_board_inherits_baud() { let mut config = ProjectConfig::new("test"); config.boards.insert("nano".to_string(), BoardProfile { fqbn: "arduino:avr:nano:cpu=atmega328".to_string(), baud: None, }); let (fqbn, baud) = config.resolve_board("nano").unwrap(); assert_eq!(fqbn, "arduino:avr:nano:cpu=atmega328"); assert_eq!(baud, 115200); } #[test] fn test_resolve_board_unknown() { let config = ProjectConfig::new("test"); assert!(config.resolve_board("esp32").is_err()); } #[test] fn test_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_migrate_old_format() { let tmp = TempDir::new().unwrap(); let old_config = r#"[project] name = "hand" anvil_version = "1.0.0" [build] fqbn = "arduino:avr:uno" warnings = "more" include_dirs = ["lib/hal", "lib/app"] extra_flags = ["-Werror"] [monitor] baud = 115200 [boards.micro] fqbn = "arduino:avr:micro" "#; fs::write(tmp.path().join(CONFIG_FILENAME), old_config).unwrap(); let config = ProjectConfig::load(tmp.path()).unwrap(); 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")); // Verify the file was rewritten let content = fs::read_to_string(tmp.path().join(CONFIG_FILENAME)).unwrap(); assert!(content.contains("default = \"uno\"")); assert!(content.contains("[boards.uno]")); // Old fqbn stays in [build] for backward compat with old scripts assert!(content.contains("fqbn = \"arduino:avr:uno\"")); } #[test] fn test_migrate_preserves_existing_boards() { let tmp = TempDir::new().unwrap(); let old_config = r#"[project] name = "test" anvil_version = "1.0.0" [build] fqbn = "arduino:avr:mega:cpu=atmega2560" warnings = "more" include_dirs = ["lib/hal", "lib/app"] extra_flags = ["-Werror"] [monitor] baud = 115200 "#; 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_set_default_in_file() { 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 = r#"[project] name = "test" anvil_version = "1.0.0" [build] warnings = "more" include_dirs = ["lib/hal", "lib/app"] extra_flags = ["-Werror"] [monitor] baud = 115200 [boards.uno] fqbn = "arduino:avr:uno" "#; 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, ""); let result = fs::read_to_string(&config_path).unwrap(); assert!(result.contains("default = \"uno\"")); } }