561 lines
16 KiB
Rust
561 lines
16 KiB
Rust
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!(
|
|
" 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 <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(§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::<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 first: anvil board --default <other-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(())
|
|
}
|
|
|
|
/// 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::<Vec<_>>().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<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)
|
|
} |