use anyhow::{Result, Context, bail}; use colored::*; use std::fs; use crate::board::presets; use crate::project::config::{ProjectConfig, CONFIG_FILENAME}; /// Resolve a human-readable name for a board identifier by checking presets. fn board_label(fqbn: &str) -> String { for p in presets::PRESETS { if p.fqbn == fqbn { return p.description.to_string(); } } fqbn.to_string() } /// List all boards configured for this project. pub fn list_boards(project_dir: Option<&str>) -> Result<()> { let project_path = resolve_project_dir(project_dir)?; let config = ProjectConfig::load(&project_path)?; println!( "{}", format!("Boards for: {}", config.project.name) .bright_cyan() .bold() ); println!(); let mut names: Vec<&String> = config.boards.keys().collect(); names.sort(); if names.is_empty() { println!( " {}", "No boards configured.".bright_black() ); } else { for name in &names { let profile = &config.boards[*name]; let baud_str = match profile.baud { Some(b) => format!("baud={}", b), None => format!("baud={}", config.monitor.baud), }; let default_marker = if *name == &config.build.default { " (default)" } else { "" }; println!( " {:9} {}{} {}", name.bright_white().bold(), board_label(&profile.fqbn).bright_cyan(), default_marker.green(), baud_str.bright_black() ); } if names.len() > 1 { println!(); if cfg!(target_os = "windows") { println!( " {}", "Use: build.bat --board ".bright_black() ); } else { println!( " {}", "Use: ./build.sh --board ".bright_black() ); } } } println!(); println!("{}", "Next steps:".bright_yellow().bold()); println!( " Add a board: {}", "anvil board --add mega".bright_cyan() ); if config.boards.len() > 1 { println!( " Set default: {}", format!( "anvil board --default {}", config.boards.keys() .find(|k| *k != &config.build.default) .unwrap_or(&config.build.default) ).bright_cyan() ); println!( " Remove a board: {}", format!( "anvil board --remove {}", config.boards.keys() .find(|k| *k != &config.build.default) .unwrap_or(&config.build.default) ).bright_cyan() ); } println!( " Browse boards: {}", "anvil board --listall".bright_cyan() ); println!(); Ok(()) } /// Show all available boards in a single unified list. pub fn listall_boards(search: Option<&str>) -> Result<()> { println!( "{}", "Boards you can add:".bright_cyan().bold() ); println!(); // Filter presets by search term let presets_to_show: Vec<_> = match search { Some(term) => { let lower = term.to_lowercase(); presets::PRESETS.iter().filter(|p| { p.name.contains(&lower) || p.description.to_lowercase().contains(&lower) || p.also_known_as.to_lowercase().contains(&lower) }).collect() } None => presets::PRESETS.iter().collect(), }; // Show presets if !presets_to_show.is_empty() { println!( "{}", "Common boards:".bright_cyan().bold() ); println!(); for p in &presets_to_show { println!( " {}", p.name.bright_white().bold() ); let mut board_names = vec![p.description.to_string()]; if !p.also_known_as.is_empty() { for alias in p.also_known_as.split(", ") { board_names.push(alias.to_string()); } } println!( " Works with: {}", board_names.join(", ").bright_black() ); println!( " Add: {}", format!("anvil board --add {}", p.name).bright_cyan() ); println!(); } } // Full catalog from arduino-cli let cli = crate::board::find_arduino_cli(); let cli_path = match cli { Some(p) => p, None => { if presets_to_show.is_empty() { println!( " {}", "No boards match your search.".bright_black() ); } println!( "{}", "Install arduino-cli to see more boards: anvil setup" .bright_yellow() ); println!(); return Ok(()); } }; let mut args = vec!["board", "listall"]; if let Some(term) = search { args.push(term); } let output = std::process::Command::new(&cli_path) .args(&args) .output() .context("Failed to run arduino-cli board listall")?; if !output.status.success() { bail!("arduino-cli board listall failed"); } let stdout = String::from_utf8_lossy(&output.stdout); let lines: Vec<&str> = stdout.lines().collect(); // Collect non-preset boards let mut other_boards: Vec<(&str, &str)> = Vec::new(); for line in lines.iter().skip(1) { let fqbn = line.split_whitespace().last().unwrap_or(""); if fqbn.is_empty() { continue; } let board_name = line.trim_end_matches(fqbn).trim(); if board_name.is_empty() { continue; } let is_preset = presets::PRESETS.iter().any(|p| p.fqbn == fqbn); if !is_preset { other_boards.push((board_name, fqbn)); } } if !other_boards.is_empty() { println!( "{}", "More boards:".bright_cyan().bold() ); println!(); for (board_name, fqbn) in &other_boards { let suggested = suggest_short_name(board_name); println!( " {}", board_name.bright_white() ); println!( " Add: {}", format!( "anvil board --add {} --id {}", suggested, fqbn ).bright_cyan() ); } println!(); } if presets_to_show.is_empty() && other_boards.is_empty() { println!( " {}", "No boards match your search.".bright_black() ); println!(); } if search.is_some() { println!( " {}", "See all: anvil board --listall".bright_black() ); } else { println!( " {}", "Search: anvil board --listall ".bright_black() ); } println!( " {}", "Remove: anvil board --remove ".bright_black() ); if cfg!(target_os = "windows") { println!( " {}", "Use: upload.bat --board ".bright_black() ); } else { println!( " {}", "Use: ./upload.sh --board ".bright_black() ); } println!(); Ok(()) } /// Suggest a short project name from a board's display name. fn suggest_short_name(board_name: &str) -> String { let lower = board_name.to_lowercase(); let stripped = lower .trim_start_matches("arduino ") .trim_start_matches("adafruit ") .trim_start_matches("sparkfun ") .trim_start_matches("seeed "); let meaningful = if let Some(pos) = stripped.find(" or ") { stripped[pos + 4..].trim() } else { stripped.trim() }; let words: Vec<&str> = meaningful .split(|c: char| !c.is_alphanumeric()) .filter(|w| { !w.is_empty() && !matches!(*w, "the" | "a" | "arduino" | "board") }) .collect(); if words.is_empty() { let fallback: Vec<&str> = stripped .split(|c: char| !c.is_alphanumeric()) .filter(|w| !w.is_empty() && *w != "arduino") .take(2) .collect(); if fallback.is_empty() { "board".to_string() } else { fallback.join("-") } } else if words.len() == 1 { words[0].to_string() } else { words.iter().take(2).copied().collect::>().join("-") } } /// Add a board to .anvil.toml. pub fn add_board( name: &str, id: Option<&str>, baud: Option, project_dir: Option<&str>, ) -> Result<()> { let project_path = resolve_project_dir(project_dir)?; let config = ProjectConfig::load(&project_path)?; if config.boards.contains_key(name) { bail!( "Board '{}' already exists.\n \ Remove it first: anvil board --remove {}", name, name ); } // Resolve the board identifier: explicit --id > preset lookup > error let resolved_id = match id { Some(f) => f.to_string(), None => { match presets::find_preset(name) { Some(preset) => { preset.fqbn.to_string() } None => { println!( "{}", format!( "'{}' is not a recognized board name.", name ).bright_yellow() ); println!(); println!( " {}", "Find your board:" ); println!( " {}", "anvil board --listall".bright_cyan() ); println!( " {}", format!("anvil board --listall {}", name).bright_cyan() ); bail!("Run anvil board --listall to find the right command."); } } } }; // Resolve baud: explicit > preset default > None (inherit) let resolved_baud = match baud { Some(b) => Some(b), None => { if let Some(preset) = presets::find_preset(name) { if preset.baud != config.monitor.baud { Some(preset.baud) } else { None } } else { None } } }; // Append to .anvil.toml as text to preserve formatting and comments let config_path = project_path.join(CONFIG_FILENAME); let mut content = fs::read_to_string(&config_path) .context("Failed to read .anvil.toml")?; let mut section = format!("\n[boards.{}]\n", name); section.push_str(&format!("fqbn = \"{}\"\n", resolved_id)); if let Some(b) = resolved_baud { section.push_str(&format!("baud = {}\n", b)); } content.push_str(§ion); fs::write(&config_path, content) .context("Failed to write .anvil.toml")?; let label = board_label(&resolved_id); println!( "{} Added board: {} ({})", "ok".green(), name.bright_white().bold(), label.bright_cyan() ); if let Some(b) = resolved_baud { println!(" baud = {}", b.to_string().bright_cyan()); } println!(); if cfg!(target_os = "windows") { println!( " {}", format!("Use: build.bat --board {}", name).bright_black() ); } else { println!( " {}", format!("Use: ./build.sh --board {}", name).bright_black() ); } println!(); Ok(()) } /// Remove a board from .anvil.toml. pub fn remove_board(name: &str, project_dir: Option<&str>) -> Result<()> { let project_path = resolve_project_dir(project_dir)?; let config = ProjectConfig::load(&project_path)?; if !config.boards.contains_key(name) { let available: Vec<&String> = config.boards.keys().collect(); if available.is_empty() { bail!("No boards configured."); } else { bail!( "No board '{}' found.\n Available: {}", name, available.iter().map(|s| s.as_str()).collect::>().join(", ") ); } } // Don't allow removing the default board if name == config.build.default { bail!( "Cannot remove '{}' because it is the default board.\n \ Change the default first: anvil board --default ", name ); } let config_path = project_path.join(CONFIG_FILENAME); let content = fs::read_to_string(&config_path) .context("Failed to read .anvil.toml")?; let section_header = format!("[boards.{}]", name); let mut output = String::new(); let mut skipping = false; for line in content.lines() { let trimmed = line.trim(); if trimmed == section_header { skipping = true; continue; } if skipping && trimmed.starts_with('[') { skipping = false; } if skipping { if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.contains('=') { continue; } skipping = false; } output.push_str(line); output.push('\n'); } while output.ends_with("\n\n\n") { output.pop(); } fs::write(&config_path, &output) .context("Failed to write .anvil.toml")?; println!( "{} Removed board: {}", "ok".green(), name.bright_white().bold() ); println!(); Ok(()) } /// Set the default board in .anvil.toml. pub fn set_default_board(name: &str, project_dir: Option<&str>) -> Result<()> { let project_path = resolve_project_dir(project_dir)?; let config = ProjectConfig::load(&project_path)?; // Verify the board exists if !config.boards.contains_key(name) { let available: Vec<&String> = config.boards.keys().collect(); if available.is_empty() { bail!( "No board '{}' found.\n \ Add it first: anvil board --add {}", name, name ); } else { bail!( "No board '{}' found.\n \ Available: {}\n \ Add it first: anvil board --add {}", name, available.iter().map(|s| s.as_str()).collect::>().join(", "), name ); } } let config_path = project_path.join(CONFIG_FILENAME); let old = crate::project::config::set_default_in_file(&config_path, name)?; let label = board_label(&config.boards[name].fqbn); println!( "{} Default board: {} ({})", "ok".green(), name.bright_white().bold(), label.bright_cyan() ); if !old.is_empty() && old != name { println!( " {}", format!("Changed from: {}", old).bright_black() ); } println!(); Ok(()) } fn resolve_project_dir(project_dir: Option<&str>) -> Result { let start = match project_dir { Some(dir) => std::path::PathBuf::from(dir), None => std::env::current_dir() .context("Could not determine current directory")?, }; ProjectConfig::find_project_root(&start) }