Files
anvil/src/project/config.rs
2026-02-19 16:58:41 -06:00

654 lines
21 KiB
Rust

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<String, BoardProfile>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub pins: HashMap<String, BoardPinConfig>,
}
#[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<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)]
pub struct MonitorConfig {
pub baud: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
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>,
}
/// 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<String, PinAssignment>,
/// Bus group reservations: "spi" -> { cs = 10 }, "i2c" -> {}
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub buses: HashMap<String, BusConfig>,
}
/// 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<String, u8>,
}
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<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
);
}
}
}
}
/// Load config from a project directory.
pub fn load(project_root: &Path) -> Result<Self> {
let config_path = project_root.join(CONFIG_FILENAME);
if !config_path.exists() {
bail!(
"Not an Anvil project (missing {}).\n\
Create one with: anvil new <name>",
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<PathBuf> {
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 <name>",
CONFIG_FILENAME,
start.display()
);
}
/// Resolve include directories to absolute paths relative to project root.
pub fn resolve_include_flags(&self, project_root: &Path) -> Vec<String> {
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<PathBuf> {
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<String> {
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\""));
}
}