Supporting multiple boards

This commit is contained in:
Eric Ratliff
2026-02-19 10:12:33 -06:00
parent 2739d83b99
commit b909da298e
16 changed files with 1554 additions and 94 deletions

505
src/commands/board.rs Normal file
View File

@@ -0,0 +1,505 @@
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 <n>".bright_black()
);
} else {
println!(
" {}",
"Use: ./build.sh --board <n>".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!(
" 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 <search>".bright_black()
);
}
println!(
" {}",
"Remove: anvil board --remove <n>".bright_black()
);
if cfg!(target_os = "windows") {
println!(
" {}",
"Use: upload.bat --board <n>".bright_black()
);
} else {
println!(
" {}",
"Use: ./upload.sh --board <n>".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::<Vec<_>>().join("-")
}
}
/// Add a board to .anvil.toml.
pub fn add_board(
name: &str,
id: Option<&str>,
baud: Option<u32>,
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(&section);
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::<Vec<_>>().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 in .anvil.toml first, or remove a different board.",
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(())
}
fn resolve_project_dir(project_dir: Option<&str>) -> Result<std::path::PathBuf> {
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)
}

View File

@@ -1,5 +1,6 @@
use anyhow::Result;
use colored::*;
use std::io::{self, Write};
use crate::board;
@@ -22,7 +23,7 @@ impl SystemHealth {
}
}
pub fn run_diagnostics() -> Result<()> {
pub fn run_diagnostics(fix: bool) -> Result<()> {
println!(
"{}",
"Checking system health...".bright_yellow().bold()
@@ -40,6 +41,19 @@ pub fn run_diagnostics() -> Result<()> {
.bright_green()
.bold()
);
if fix {
// Check optional tools
let optional_missing = !health.cmake_ok
|| !health.cpp_compiler_ok
|| !health.git_ok;
if optional_missing {
println!();
run_fix_optional(&health)?;
}
}
} else if fix {
println!();
run_fix(&health)?;
} else {
println!(
"{}",
@@ -49,6 +63,11 @@ pub fn run_diagnostics() -> Result<()> {
);
println!();
print_fix_instructions(&health);
println!(
" {}",
"Or run: anvil doctor --fix (to install missing tools automatically)"
.bright_cyan()
);
}
println!();
@@ -155,7 +174,7 @@ fn print_diagnostics(health: &SystemHealth) {
println!("{}", "Optional:".bright_yellow().bold());
println!();
// avr-size -- installed as part of the avr core, not a separate step
// avr-size
if health.avr_size_ok {
println!(" {} avr-size (binary size reporting)", "ok".green());
} else if !health.avr_core_ok {
@@ -165,9 +184,6 @@ fn print_diagnostics(health: &SystemHealth) {
"included with arduino:avr core (no separate install)".yellow()
);
} else {
// Core is installed but avr-size is not on PATH --
// this can happen on Windows where the tool is buried
// inside the Arduino15 packages directory.
println!(
" {} avr-size {}",
"na".yellow(),
@@ -300,7 +316,6 @@ fn print_fix_instructions(health: &SystemHealth) {
}
if !health.arduino_cli_ok {
// They need to open a new terminal after installing arduino-cli
println!(
" {}. {}",
step,
@@ -353,6 +368,365 @@ fn print_fix_instructions(health: &SystemHealth) {
}
}
// ==========================================================================
// --fix: automated installation
// ==========================================================================
/// Prompt the user for yes/no confirmation.
fn confirm(prompt: &str) -> bool {
print!("{} [Y/n] ", prompt);
io::stdout().flush().ok();
let mut input = String::new();
if io::stdin().read_line(&mut input).is_err() {
return false;
}
let trimmed = input.trim().to_lowercase();
trimmed.is_empty() || trimmed == "y" || trimmed == "yes"
}
/// Run a command, streaming output to the terminal.
fn run_cmd(program: &str, args: &[&str]) -> bool {
println!(
" {} {} {}",
"$".bright_black(),
program.bright_cyan(),
args.join(" ").bright_cyan()
);
std::process::Command::new(program)
.args(args)
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Detect which package manager is available on the system.
fn detect_package_manager() -> Option<&'static str> {
if cfg!(target_os = "windows") {
if which::which("winget").is_ok() {
Some("winget")
} else if which::which("choco").is_ok() {
Some("choco")
} else {
None
}
} else if cfg!(target_os = "macos") {
if which::which("brew").is_ok() {
Some("brew")
} else {
None
}
} else {
// Linux
if which::which("apt").is_ok() {
Some("apt")
} else if which::which("dnf").is_ok() {
Some("dnf")
} else if which::which("pacman").is_ok() {
Some("pacman")
} else {
None
}
}
}
/// Fix required items (arduino-cli, avr core).
fn run_fix(health: &SystemHealth) -> Result<()> {
let pm = detect_package_manager();
// -- arduino-cli --
if !health.arduino_cli_ok {
println!(
"{}",
"arduino-cli is required but not installed.".bright_yellow()
);
match pm {
Some("winget") => {
if confirm("Install arduino-cli via winget?") {
if !run_cmd("winget", &["install", "--id", "ArduinoSA.CLI", "-e"]) {
println!("{} winget install failed. Try installing manually.", "FAIL".red());
return Ok(());
}
println!(
"{} arduino-cli installed. {} to update PATH.",
"ok".green(),
"Restart your terminal".bright_yellow()
);
return Ok(());
}
}
Some("choco") => {
if confirm("Install arduino-cli via Chocolatey?") {
if !run_cmd("choco", &["install", "arduino-cli", "-y"]) {
println!("{} choco install failed. Try installing manually.", "FAIL".red());
return Ok(());
}
println!(
"{} arduino-cli installed. {} to update PATH.",
"ok".green(),
"Restart your terminal".bright_yellow()
);
return Ok(());
}
}
Some("brew") => {
if confirm("Install arduino-cli via Homebrew?") {
if !run_cmd("brew", &["install", "arduino-cli"]) {
println!("{} brew install failed.", "FAIL".red());
return Ok(());
}
println!("{} arduino-cli installed.", "ok".green());
}
}
Some("apt") => {
if confirm("Install arduino-cli via apt?") {
if !run_cmd("sudo", &["apt", "install", "-y", "arduino-cli"]) {
println!(
"{} apt install failed. Try the curl installer instead:",
"FAIL".red()
);
println!(
" {}",
"curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh"
.bright_cyan()
);
return Ok(());
}
println!("{} arduino-cli installed.", "ok".green());
}
}
Some("dnf") => {
println!(
" arduino-cli is not in dnf repos. Install manually:"
);
println!(
" {}",
"curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh"
.bright_cyan()
);
return Ok(());
}
Some("pacman") => {
if confirm("Install arduino-cli via pacman (AUR)?") {
if !run_cmd("yay", &["-S", "--noconfirm", "arduino-cli"]) {
println!("{} AUR install failed. Try the curl installer.", "FAIL".red());
return Ok(());
}
println!("{} arduino-cli installed.", "ok".green());
}
}
_ => {
println!(
" No supported package manager found. Install manually:"
);
println!(
" {}",
"https://arduino.github.io/arduino-cli/installation/"
.bright_cyan()
);
return Ok(());
}
}
// Re-check after install
if board::find_arduino_cli().is_none() {
println!();
println!(
"{} arduino-cli not found after install. You may need to restart your terminal.",
"warn".yellow()
);
return Ok(());
}
}
// -- AVR core --
if !health.avr_core_ok {
if health.arduino_cli_ok || board::find_arduino_cli().is_some() {
println!();
println!(
"{}",
"arduino:avr core is required but not installed.".bright_yellow()
);
if confirm("Install arduino:avr core now?") {
let cli = board::find_arduino_cli()
.unwrap_or_else(|| std::path::PathBuf::from("arduino-cli"));
let cli_str = cli.to_string_lossy();
if !run_cmd(&cli_str, &["core", "install", "arduino:avr"]) {
println!("{} Core installation failed.", "FAIL".red());
return Ok(());
}
println!("{} arduino:avr core installed.", "ok".green());
}
}
}
// Offer optional tools too
run_fix_optional(health)?;
Ok(())
}
/// Fix optional items (cmake, C++ compiler, git).
fn run_fix_optional(health: &SystemHealth) -> Result<()> {
let pm = detect_package_manager();
let items: Vec<(&str, bool, FixSpec)> = vec![
("cmake", health.cmake_ok, fix_spec_cmake(pm)),
("C++ compiler", health.cpp_compiler_ok, fix_spec_cpp(pm)),
("git", health.git_ok, fix_spec_git(pm)),
];
let missing: Vec<_> = items.iter().filter(|(_, ok, _)| !ok).collect();
if missing.is_empty() {
return Ok(());
}
println!();
println!("{}", "Optional tools:".bright_yellow().bold());
for (name, _, spec) in &missing {
match spec {
FixSpec::Auto { prompt, program, args } => {
if confirm(prompt) {
if run_cmd(program, args) {
println!("{} {} installed.", "ok".green(), name);
} else {
println!("{} Failed to install {}.", "FAIL".red(), name);
}
}
}
FixSpec::Manual { message } => {
println!(" {}: {}", name, message.bright_black());
}
}
}
Ok(())
}
enum FixSpec {
Auto {
prompt: &'static str,
program: &'static str,
args: &'static [&'static str],
},
Manual {
message: &'static str,
},
}
fn fix_spec_cmake(pm: Option<&str>) -> FixSpec {
match pm {
Some("winget") => FixSpec::Auto {
prompt: "Install cmake via winget?",
program: "winget",
args: &["install", "--id", "Kitware.CMake", "-e"],
},
Some("choco") => FixSpec::Auto {
prompt: "Install cmake via Chocolatey?",
program: "choco",
args: &["install", "cmake", "-y"],
},
Some("brew") => FixSpec::Auto {
prompt: "Install cmake via Homebrew?",
program: "brew",
args: &["install", "cmake"],
},
Some("apt") => FixSpec::Auto {
prompt: "Install cmake via apt?",
program: "sudo",
args: &["apt", "install", "-y", "cmake"],
},
Some("dnf") => FixSpec::Auto {
prompt: "Install cmake via dnf?",
program: "sudo",
args: &["dnf", "install", "-y", "cmake"],
},
Some("pacman") => FixSpec::Auto {
prompt: "Install cmake via pacman?",
program: "sudo",
args: &["pacman", "-S", "--noconfirm", "cmake"],
},
_ => FixSpec::Manual {
message: "install from https://cmake.org/download/",
},
}
}
fn fix_spec_cpp(pm: Option<&str>) -> FixSpec {
match pm {
Some("winget") => FixSpec::Auto {
prompt: "Install Visual Studio Build Tools via winget?",
program: "winget",
args: &["install", "--id", "Microsoft.VisualStudio.2022.BuildTools", "-e"],
},
Some("choco") => FixSpec::Auto {
prompt: "Install MinGW g++ via Chocolatey?",
program: "choco",
args: &["install", "mingw", "-y"],
},
Some("brew") => FixSpec::Manual {
message: "run: xcode-select --install",
},
Some("apt") => FixSpec::Auto {
prompt: "Install g++ via apt?",
program: "sudo",
args: &["apt", "install", "-y", "g++"],
},
Some("dnf") => FixSpec::Auto {
prompt: "Install g++ via dnf?",
program: "sudo",
args: &["dnf", "install", "-y", "gcc-c++"],
},
Some("pacman") => FixSpec::Auto {
prompt: "Install g++ via pacman?",
program: "sudo",
args: &["pacman", "-S", "--noconfirm", "gcc"],
},
_ => FixSpec::Manual {
message: "install a C++ compiler (g++, clang++, or MSVC)",
},
}
}
fn fix_spec_git(pm: Option<&str>) -> FixSpec {
match pm {
Some("winget") => FixSpec::Auto {
prompt: "Install git via winget?",
program: "winget",
args: &["install", "--id", "Git.Git", "-e"],
},
Some("choco") => FixSpec::Auto {
prompt: "Install git via Chocolatey?",
program: "choco",
args: &["install", "git", "-y"],
},
Some("brew") => FixSpec::Auto {
prompt: "Install git via Homebrew?",
program: "brew",
args: &["install", "git"],
},
Some("apt") => FixSpec::Auto {
prompt: "Install git via apt?",
program: "sudo",
args: &["apt", "install", "-y", "git"],
},
Some("dnf") => FixSpec::Auto {
prompt: "Install git via dnf?",
program: "sudo",
args: &["dnf", "install", "-y", "git"],
},
Some("pacman") => FixSpec::Auto {
prompt: "Install git via pacman?",
program: "sudo",
args: &["pacman", "-S", "--noconfirm", "git"],
},
_ => FixSpec::Manual {
message: "install from https://git-scm.com",
},
}
}
// ---------------------------------------------------------------------------
// Platform-aware install hints (one-liners for the diagnostics table)
// ---------------------------------------------------------------------------

View File

@@ -2,4 +2,5 @@ pub mod new;
pub mod doctor;
pub mod setup;
pub mod devices;
pub mod refresh;
pub mod refresh;
pub mod board;

View File

@@ -48,7 +48,6 @@ pub fn list_boards() -> Result<()> {
if !preset.also_known_as.is_empty() {
println!(" Also: {}", preset.also_known_as.bright_black());
}
println!(" FQBN: {}", preset.fqbn.bright_black());
println!();
}
@@ -59,11 +58,11 @@ pub fn list_boards() -> Result<()> {
println!();
println!(
" {}",
"For boards not listed here, create a project and edit the".bright_black()
"For boards not listed here, create a project and then:".bright_black()
);
println!(
" {}",
"fqbn value in .anvil.toml to any valid arduino-cli FQBN.".bright_black()
" anvil board --listall".bright_black()
);
println!();
@@ -128,10 +127,6 @@ pub fn create_project(
"{}",
format!("Board: {} ({})", preset.name, preset.description).bright_cyan()
);
println!(
"{}",
format!("FQBN: {}", preset.fqbn).bright_black()
);
println!();
// Create project directory
@@ -142,6 +137,7 @@ pub fn create_project(
let context = TemplateContext {
project_name: name.to_string(),
anvil_version: ANVIL_VERSION.to_string(),
board_name: preset.name.to_string(),
fqbn: preset.fqbn.to_string(),
baud: preset.baud,
};

View File

@@ -47,7 +47,8 @@ pub fn run_refresh(project_dir: Option<&str>, force: bool) -> Result<()> {
let context = TemplateContext {
project_name: config.project.name.clone(),
anvil_version: ANVIL_VERSION.to_string(),
fqbn: config.build.fqbn.clone(),
board_name: config.build.default.clone(),
fqbn: config.default_fqbn().unwrap_or_else(|_| "arduino:avr:uno".to_string()),
baud: config.monitor.baud,
};

View File

@@ -45,7 +45,11 @@ enum Commands {
},
/// Check system health and diagnose issues
Doctor,
Doctor {
/// Automatically install missing tools
#[arg(long)]
fix: bool,
},
/// Install arduino-cli and required cores
Setup,
@@ -81,6 +85,36 @@ enum Commands {
#[arg(long)]
force: bool,
},
/// Manage board profiles in .anvil.toml
Board {
/// Board name (e.g. mega, nano)
name: Option<String>,
/// Add a board to the project
#[arg(long, conflicts_with_all = ["remove", "listall"])]
add: bool,
/// Remove a board from the project
#[arg(long, conflicts_with_all = ["add", "listall"])]
remove: bool,
/// Browse all available boards
#[arg(long, conflicts_with_all = ["add", "remove"])]
listall: bool,
/// Board identifier (from anvil board --listall)
#[arg(long, value_name = "ID")]
id: Option<String>,
/// Baud rate override
#[arg(long, value_name = "RATE")]
baud: Option<u32>,
/// Path to project directory (defaults to current directory)
#[arg(long, short = 'd', value_name = "DIR")]
dir: Option<String>,
},
}
fn main() -> Result<()> {
@@ -113,8 +147,8 @@ fn main() -> Result<()> {
);
}
}
Commands::Doctor => {
commands::doctor::run_diagnostics()
Commands::Doctor { fix } => {
commands::doctor::run_diagnostics(fix)
}
Commands::Setup => {
commands::setup::run_setup()
@@ -143,6 +177,38 @@ fn main() -> Result<()> {
force,
)
}
Commands::Board { name, add, remove, listall, id, baud, dir } => {
if listall {
commands::board::listall_boards(name.as_deref())
} else if add {
let board_name = name.as_deref().ok_or_else(|| {
anyhow::anyhow!(
"Board name required.\n\
Usage: anvil board --add mega\n\
Browse available boards: anvil board --listall"
)
})?;
commands::board::add_board(
board_name,
id.as_deref(),
baud,
dir.as_deref(),
)
} else if remove {
let board_name = name.as_deref().ok_or_else(|| {
anyhow::anyhow!(
"Board name required.\n\
Usage: anvil board --remove mega"
)
})?;
commands::board::remove_board(
board_name,
dir.as_deref(),
)
} else {
commands::board::list_boards(dir.as_deref())
}
}
}
}

View File

@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::fs;
use anyhow::{Result, Context, bail};
@@ -12,6 +13,8 @@ pub struct ProjectConfig {
pub project: ProjectMeta,
pub build: BuildConfig,
pub monitor: MonitorConfig,
#[serde(default)]
pub boards: HashMap<String, BoardProfile>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -22,10 +25,15 @@ pub struct ProjectMeta {
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BuildConfig {
pub fqbn: String,
/// Name of the default board from [boards.*]
#[serde(default)]
pub default: String,
pub warnings: String,
pub include_dirs: Vec<String>,
pub extra_flags: Vec<String>,
/// Legacy: FQBN used to live here directly. Now lives in [boards.*].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fqbn: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -35,16 +43,30 @@ pub struct MonitorConfig {
pub port: Option<String>,
}
/// 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<u32>,
}
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 {
fqbn: "arduino:avr:uno".to_string(),
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()],
@@ -53,6 +75,61 @@ impl ProjectConfig {
baud: 115200,
port: None,
},
boards,
}
}
/// 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<String> {
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::<Vec<_>>().join(", "),
board_name
);
}
}
}
}
@@ -68,8 +145,24 @@ impl ProjectConfig {
}
let contents = fs::read_to_string(&config_path)
.context(format!("Failed to read {}", config_path.display()))?;
let config: ProjectConfig = toml::from_str(&contents)
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(legacy_fqbn) = config.build.fqbn.take() {
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());
config.boards.entry(board_name.clone()).or_insert(BoardProfile {
fqbn: legacy_fqbn,
baud: None,
});
config.build.default = board_name;
}
}
Ok(config)
}
@@ -155,9 +248,11 @@ mod tests {
fn test_new_config_defaults() {
let config = ProjectConfig::new("test_project");
assert_eq!(config.project.name, "test_project");
assert_eq!(config.build.fqbn, "arduino:avr:uno");
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]
@@ -168,8 +263,20 @@ mod tests {
let loaded = ProjectConfig::load(tmp.path()).unwrap();
assert_eq!(loaded.project.name, "roundtrip");
assert_eq!(loaded.build.fqbn, config.build.fqbn);
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]
@@ -178,7 +285,6 @@ mod tests {
let config = ProjectConfig::new("finder");
config.save(tmp.path()).unwrap();
// Create a subdirectory and search from there
let sub = tmp.path().join("sketch").join("deep");
fs::create_dir_all(&sub).unwrap();
@@ -219,4 +325,61 @@ mod tests {
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));
}
}

View File

@@ -10,6 +10,7 @@ static BASIC_TEMPLATE: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/basic")
pub struct TemplateContext {
pub project_name: String,
pub anvil_version: String,
pub board_name: String,
pub fqbn: String,
pub baud: u32,
}
@@ -142,6 +143,7 @@ fn substitute_variables(text: &str, context: &TemplateContext) -> String {
text.replace("{{PROJECT_NAME}}", &context.project_name)
.replace("{{ANVIL_VERSION}}", &context.anvil_version)
.replace("{{ANVIL_VERSION_CURRENT}}", ANVIL_VERSION)
.replace("{{BOARD_NAME}}", &context.board_name)
.replace("{{FQBN}}", &context.fqbn)
.replace("{{BAUD}}", &context.baud.to_string())
}
@@ -176,14 +178,15 @@ mod tests {
let ctx = TemplateContext {
project_name: "my_project".to_string(),
anvil_version: "1.0.0".to_string(),
board_name: "mega".to_string(),
fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(),
baud: 9600,
};
let input = "Name: {{PROJECT_NAME}}, Version: {{ANVIL_VERSION}}, FQBN: {{FQBN}}, Baud: {{BAUD}}";
let input = "Name: {{PROJECT_NAME}}, Board: {{BOARD_NAME}}, FQBN: {{FQBN}}, Baud: {{BAUD}}";
let output = substitute_variables(input, &ctx);
assert_eq!(
output,
"Name: my_project, Version: 1.0.0, FQBN: arduino:avr:mega:cpu=atmega2560, Baud: 9600"
"Name: my_project, Board: mega, FQBN: arduino:avr:mega:cpu=atmega2560, Baud: 9600"
);
}
@@ -199,6 +202,7 @@ mod tests {
let ctx = TemplateContext {
project_name: "test_proj".to_string(),
anvil_version: "1.0.0".to_string(),
board_name: "uno".to_string(),
fqbn: "arduino:avr:uno".to_string(),
baud: 115200,
};
@@ -231,6 +235,7 @@ mod tests {
let ctx = TemplateContext {
project_name: "my_sensor".to_string(),
anvil_version: "1.0.0".to_string(),
board_name: "uno".to_string(),
fqbn: "arduino:avr:uno".to_string(),
baud: 115200,
};