feat: Layer 2 device library system with TMP36 reference driver

Add embedded device library registry with full lifecycle management,
automated test integration, and pin assignment workflow.

Library system:
- Library registry embedded in binary via include_dir! (same as templates)
- library.toml metadata format: name, version, bus type, pins, provided files
- anvil add <name> extracts headers to lib/drivers/<name>/, test files to test/
- anvil remove <name> cleans up all files, config entries, and include paths
- anvil lib lists installed libraries with status
- anvil lib --available shows registry with student-friendly wiring summary
- [libraries] section in .anvil.toml tracks installed libraries and versions
- CMake auto-discovers lib/drivers/*/ for include paths at configure time

TMP36 analog temperature sensor (reference implementation):
- TempSensor abstract interface (readCelsius/readFahrenheit/readRaw)
- Tmp36Analog: real hardware impl via Hal::analogRead with configurable Vref
- Tmp36Mock: programmable values, read counting, no hardware needed
- Tmp36Sim: deterministic noise via seeded LCG for repeatable system tests
- test_tmp36.cpp: 21 Google Test cases covering mock, analog, sim, polymorphism
- TMP36 formula: voltage_mV = raw * Vref_mV / 1024, temp_C = (mV - 500) / 10

Automated test integration:
- Library test files (test_*.cpp) route to test/ during extraction
- CMakeLists.txt auto-discovers test_*.cpp via GLOB, builds each as own target
- anvil remove cleans up test files alongside driver headers
- Zero manual CMake editing: add library, run test --clean, tests appear

Pin assignment integration:
- anvil add <name> --pin A0 does extract + pin assignment in one step
- Without --pin, prints step-by-step wiring guidance with copy-paste commands
- anvil pin --audit flags installed libraries with unassigned pins
- Audit works even with zero existing pin assignments (fixed early-return bug)
- LibraryMeta helpers: wiring_summary(), pin_roles(), default_mode()
- Bus-aware guidance: analog pins, I2C bus registration, SPI with CS selection

UX improvements:
- anvil lib --available shows "Needs: 1 analog pin (e.g. A0)" not raw metadata
- anvil add prints app code example, test code example, and next step
- anvil pin --audit prints exact commands to resolve missing library pins
- anvil remove shows test file deletion in output

Files added:
- libraries/tmp36/library.toml
- libraries/tmp36/src/tmp36.h, tmp36_analog.h, tmp36_mock.h, tmp36_sim.h
- libraries/tmp36/src/test_tmp36.cpp
- src/library/mod.rs

Files modified:
- src/lib.rs, src/main.rs, src/commands/mod.rs
- src/commands/lib.rs (add/remove/list/list_available with --pin support)
- src/commands/pin.rs (audit library pin warnings, print_library_pin_warnings)
- src/project/config.rs (libraries HashMap field)
- templates/basic/test/CMakeLists.txt.tmpl (driver + test auto-discovery)

Tests: 254 total (89 unit + 165 integration)
- 12 library/mod.rs unit tests (registry, extraction, helpers)
- 2 commands/lib.rs unit tests (class name derivation)
- 30+ new integration tests covering library lifecycle, pin integration,
  audit flows, file routing, CMake discovery, config roundtrips, ASCII
  compliance, polymorphism contracts, and idempotent add/remove cycles
This commit is contained in:
Eric Ratliff
2026-02-21 11:46:22 -06:00
parent aa1e9d5043
commit 706f420aaa
15 changed files with 2225 additions and 19 deletions

523
src/commands/lib.rs Normal file
View File

@@ -0,0 +1,523 @@
use anyhow::{Result, bail};
use colored::*;
use std::path::PathBuf;
use crate::library;
use crate::project::config::ProjectConfig;
/// Add a library to the current project.
pub fn add_library(name: &str, pin: Option<&str>, project_dir: Option<&str>) -> Result<()> {
let meta = library::find_library(name)
.ok_or_else(|| {
let available: Vec<String> = library::list_available()
.iter()
.map(|l| l.name.clone())
.collect();
anyhow::anyhow!(
"Unknown library: '{}'\n Available: {}",
name,
if available.is_empty() {
"(none)".to_string()
} else {
available.join(", ")
}
)
})?;
let project_path = match project_dir {
Some(dir) => PathBuf::from(dir),
None => std::env::current_dir()?,
};
let project_root = ProjectConfig::find_project_root(&project_path)?;
let mut config = ProjectConfig::load(&project_root)?;
// Check if already installed
if config.libraries.contains_key(name) {
println!(
"{} {} is already installed (version {}).",
"ok".green(),
name.bright_white().bold(),
config.libraries[name]
);
println!(
" To reinstall, remove it first: {}",
format!("anvil remove {}", name).bright_cyan()
);
return Ok(());
}
// Extract files
println!(
"Adding {} {}",
meta.name.bright_white().bold(),
format!("({})", meta.description).bright_black()
);
let written = library::extract_library(name, &project_root)?;
for f in &written {
println!(" {} {}", "+".bright_green(), f.bright_white());
}
// Add to include_dirs if not present
let driver_include = format!("lib/drivers/{}", name);
if !config.build.include_dirs.contains(&driver_include) {
config.build.include_dirs.push(driver_include);
}
// Track in config
config.libraries.insert(name.to_string(), meta.version.clone());
config.save(&project_root)?;
// If --pin was given and this is a simple analog/digital library, assign it now
if let Some(pin_str) = pin {
if meta.bus == "i2c" || meta.bus == "spi" {
println!();
println!(
" {} --pin is not used for {} libraries (bus pins are fixed).",
"note:".bright_yellow(),
meta.bus.bright_cyan()
);
if meta.bus == "i2c" {
println!(
" Assign the bus: {}",
"anvil pin --assign i2c".bright_cyan()
);
} else {
println!(
" Assign the bus: {}",
"anvil pin --assign spi --cs 10".bright_cyan()
);
}
} else {
// Assign each pin role to the given pin
// For single-pin libraries this is straightforward
// For multi-pin, the user would need to run multiple commands
if meta.pins.len() == 1 {
let assign_name = meta.pin_assignment_name(&meta.pins[0]);
println!();
println!("{}", "Assigning pin:".bright_yellow().bold());
// Call pin assignment through the commands module
match crate::commands::pin::assign_pin(
&assign_name, pin_str,
Some(meta.default_mode()),
None, project_dir,
) {
Ok(()) => {}
Err(e) => {
println!(
" {} Pin assignment failed: {}",
"!!".red(),
e
);
println!(
" You can assign it manually: {}",
format!(
"anvil pin --assign {} {} --mode {}",
assign_name, pin_str, meta.default_mode()
).bright_cyan()
);
}
}
} else {
println!();
println!(
" {} This library needs {} pins. Assign them individually:",
"note:".bright_yellow(),
meta.pins.len()
);
for (_role, assign_name) in meta.pin_roles() {
println!(
" {}",
format!(
"anvil pin --assign {} <PIN> --mode {}",
assign_name, meta.default_mode()
).bright_cyan()
);
}
}
}
}
// Print usage help
println!();
println!("{}", "In your app code:".bright_yellow().bold());
println!(
" #include \"{}\"",
meta.interface.bright_white()
);
println!(
" #include \"{}\"",
meta.implementation.bright_white()
);
match meta.bus.as_str() {
"analog" => {
let pin_example = pin.unwrap_or("A0");
println!(
" {} sensor(&hal, {});",
impl_class_name(&meta.implementation).bright_cyan(),
pin_example
);
println!(" float temp = sensor.readCelsius();");
}
_ => {
println!(
" {} sensor(&hal);",
impl_class_name(&meta.implementation).bright_cyan()
);
}
}
println!();
println!("{}", "In your tests:".bright_yellow().bold());
println!(
" #include \"{}\"",
meta.mock.bright_white()
);
println!(
" {} sensor;",
mock_class_name(&meta.mock).bright_cyan()
);
println!(" sensor.setTemperature(25.0f);");
println!(
" // Your app sees 25 C -- no hardware needed"
);
// Next steps
println!();
if pin.is_none() && meta.bus != "i2c" && meta.bus != "spi" {
println!("{}", "Next step:".bright_yellow().bold());
print_wiring_guide(&meta, None);
} else if pin.is_none() && (meta.bus == "i2c" || meta.bus == "spi") {
println!("{}", "Next step:".bright_yellow().bold());
print_bus_guide(&meta);
}
println!(
"{} {} {} added. Run {} to rebuild tests.",
"ok".green(),
meta.name.bright_white().bold(),
meta.version.bright_black(),
"./test.sh --clean".bright_cyan()
);
Ok(())
}
/// Print wiring guidance for analog/digital libraries.
fn print_wiring_guide(meta: &library::LibraryMeta, board: Option<&str>) {
let board_flag = board.map(|b| format!(" --board {}", b)).unwrap_or_default();
match meta.bus.as_str() {
"analog" => {
println!(
" Wire the {} sensor signal pin to an analog input (e.g. {}).",
meta.name.bright_white(),
"A0".bright_cyan()
);
println!(
" Then tell Anvil which pin you chose:"
);
println!();
for (_role, assign_name) in meta.pin_roles() {
println!(
" {}",
format!(
"anvil pin --assign {} A0 --mode analog{}",
assign_name, board_flag
).bright_cyan()
);
}
println!();
println!(
" {}",
"Pick any analog pin (A0-A5 on Uno). The pin you choose here"
.bright_black()
);
println!(
" {}",
"must match the physical wire AND the pin in your code."
.bright_black()
);
}
"digital" => {
println!(
" Wire the {} sensor to a digital pin.",
meta.name.bright_white()
);
println!(
" Then tell Anvil which pin you chose:"
);
println!();
for (_role, assign_name) in meta.pin_roles() {
println!(
" {}",
format!(
"anvil pin --assign {} <PIN> --mode input{}",
assign_name, board_flag
).bright_cyan()
);
}
}
_ => {
for (role, assign_name) in meta.pin_roles() {
println!(
" Assign {} pin: {}",
role.bright_white(),
format!(
"anvil pin --assign {} <PIN>{}",
assign_name, board_flag
).bright_cyan()
);
}
}
}
println!();
}
/// Print wiring guidance for I2C/SPI bus libraries.
fn print_bus_guide(meta: &library::LibraryMeta) {
match meta.bus.as_str() {
"i2c" => {
println!(
" Wire the {} sensor to the I2C bus (SDA + SCL pins).",
meta.name.bright_white()
);
println!(
" Then register the bus:"
);
println!();
println!(
" {}",
"anvil pin --assign i2c".bright_cyan()
);
println!();
println!(
" {}",
"SDA and SCL are fixed pins on your board (A4/A5 on Uno)."
.bright_black()
);
println!(
" {}",
"Anvil knows which pins they are -- just register the bus."
.bright_black()
);
}
"spi" => {
println!(
" Wire the {} sensor to the SPI bus (MOSI/MISO/SCK) with a CS pin.",
meta.name.bright_white()
);
println!(
" Then register the bus with your chosen CS pin:"
);
println!();
println!(
" {}",
"anvil pin --assign spi --cs 10".bright_cyan()
);
println!();
println!(
" {}",
"MOSI/MISO/SCK are fixed on your board. You pick the CS pin."
.bright_black()
);
}
_ => {}
}
println!();
}
/// Remove a library from the current project.
pub fn remove_library(name: &str, project_dir: Option<&str>) -> Result<()> {
let project_path = match project_dir {
Some(dir) => PathBuf::from(dir),
None => std::env::current_dir()?,
};
let project_root = ProjectConfig::find_project_root(&project_path)?;
let mut config = ProjectConfig::load(&project_root)?;
if !config.libraries.contains_key(name)
&& !library::is_installed_on_disk(name, &project_root)
{
bail!(
"Library '{}' is not installed.\n See installed: anvil lib\n See available: anvil lib --available",
name
);
}
// Remove files
let test_file = project_root.join("test").join(format!("test_{}.cpp", name));
let had_test = test_file.exists();
library::remove_library_files(name, &project_root)?;
println!(
" {} lib/drivers/{}/",
"-".bright_red(),
name.bright_white()
);
if had_test {
println!(
" {} test/test_{}.cpp",
"-".bright_red(),
name.bright_white()
);
}
// Remove from include_dirs
let driver_include = format!("lib/drivers/{}", name);
config.build.include_dirs.retain(|d| d != &driver_include);
// Remove from tracked libraries
config.libraries.remove(name);
config.save(&project_root)?;
println!();
println!(
"{} {} removed.",
"ok".green(),
name.bright_white().bold()
);
Ok(())
}
/// List installed libraries in the current project.
pub fn list_libraries(project_dir: Option<&str>) -> Result<()> {
let project_path = match project_dir {
Some(dir) => PathBuf::from(dir),
None => std::env::current_dir()?,
};
let project_root = ProjectConfig::find_project_root(&project_path)?;
let config = ProjectConfig::load(&project_root)?;
if config.libraries.is_empty() {
println!("No libraries installed.");
println!();
println!(
" Browse available: {}",
"anvil lib --available".bright_cyan()
);
println!(
" Add one: {}",
"anvil add tmp36".bright_cyan()
);
return Ok(());
}
println!("{}", "Installed libraries:".bright_yellow().bold());
println!();
for (name, version) in &config.libraries {
let on_disk = library::is_installed_on_disk(name, &project_root);
let meta = library::find_library(name);
let desc = meta
.as_ref()
.map(|m| m.description.as_str())
.unwrap_or("(unknown)");
if on_disk {
println!(
" {} {} {} {}",
"ok".green(),
name.bright_white().bold(),
version.bright_black(),
desc.bright_black()
);
} else {
println!(
" {} {} {} {}",
"!!".red(),
name.bright_white().bold(),
version.bright_black(),
"files missing -- run: anvil add ".red()
);
}
}
Ok(())
}
/// List all available libraries in the Anvil registry.
pub fn list_available() -> Result<()> {
let libs = library::list_available();
if libs.is_empty() {
println!("No libraries available.");
return Ok(());
}
println!("{}", "Available libraries:".bright_yellow().bold());
println!();
for lib in &libs {
println!(
" {} {} {}",
lib.name.bright_white().bold(),
lib.version.bright_black(),
lib.description
);
println!(
" Needs: {}",
lib.wiring_summary().bright_cyan()
);
// Show inline pin example for simple cases
match lib.bus.as_str() {
"analog" if lib.pins.len() == 1 => {
println!(
" Add: {}",
format!("anvil add {} --pin A0", lib.name).bright_cyan()
);
}
_ => {
println!(
" Add: {}",
format!("anvil add {}", lib.name).bright_cyan()
);
}
}
println!();
}
Ok(())
}
/// Derive class name from implementation filename.
/// "tmp36_analog.h" -> "Tmp36Analog"
fn impl_class_name(filename: &str) -> String {
let stem = filename.trim_end_matches(".h");
stem.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(c) => {
let mut s = c.to_uppercase().to_string();
s.push_str(&chars.collect::<String>());
s
}
None => String::new(),
}
})
.collect()
}
/// Derive mock class name from mock filename.
/// "tmp36_mock.h" -> "Tmp36Mock"
fn mock_class_name(filename: &str) -> String {
impl_class_name(filename)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_impl_class_name() {
assert_eq!(impl_class_name("tmp36_analog.h"), "Tmp36Analog");
assert_eq!(impl_class_name("bmp280_i2c.h"), "Bmp280I2c");
assert_eq!(impl_class_name("servo.h"), "Servo");
}
#[test]
fn test_mock_class_name() {
assert_eq!(mock_class_name("tmp36_mock.h"), "Tmp36Mock");
}
}

View File

@@ -4,4 +4,5 @@ pub mod setup;
pub mod devices;
pub mod refresh;
pub mod board;
pub mod pin;
pub mod pin;
pub mod lib;

View File

@@ -7,6 +7,7 @@ use std::path::PathBuf;
use crate::board::pinmap::{
self, BoardPinMap, ALL_CAPABILITIES, ALL_MODES,
};
use crate::library;
use crate::project::config::{
ProjectConfig, BoardPinConfig, PinAssignment, BusConfig, CONFIG_FILENAME,
};
@@ -393,16 +394,22 @@ pub fn audit_pins(
if pc.assignments.is_empty() && pc.buses.is_empty() {
println!(" {}", "No pin assignments configured.".bright_black());
println!();
println!(" Get started:");
println!(
" {}",
format!("anvil pin --assign led 13 --board {}", board).bright_black()
);
println!(
" {}",
format!("anvil pin --assign i2c --board {}", board).bright_black()
);
println!();
// Even with no manual assignments, libraries may need pins
let has_library_warnings = print_library_pin_warnings(&config, pc);
if !has_library_warnings {
println!(" Get started:");
println!(
" {}",
format!("anvil pin --assign led 13 --board {}", board).bright_black()
);
println!(
" {}",
format!("anvil pin --assign i2c --board {}", board).bright_black()
);
println!();
}
return Ok(());
}
@@ -512,6 +519,9 @@ pub fn audit_pins(
}
}
// Library pin check
print_library_pin_warnings(&config, pc);
// Wiring checklist
println!(" {}", "Wiring Checklist:".bold());
let mut all_wiring: Vec<(u8, String, String)> = Vec::new();
@@ -554,6 +564,126 @@ pub fn audit_pins(
Ok(())
}
/// Check installed libraries for unassigned pins and print warnings.
/// Returns true if any warnings were printed.
fn print_library_pin_warnings(config: &ProjectConfig, pc: &BoardPinConfig) -> bool {
if config.libraries.is_empty() {
return false;
}
let assigned_names: Vec<String> = pc.assignments.keys().cloned().collect();
let mut missing_any = false;
for (lib_name, _version) in &config.libraries {
if let Some(meta) = library::find_library(lib_name) {
// Bus-based libraries (i2c/spi) use bus assignments
if meta.bus == "i2c" || meta.bus == "spi" {
if !pc.buses.contains_key(&meta.bus) {
if !missing_any {
println!(" {}", "Library Wiring:".bright_yellow().bold());
missing_any = true;
}
println!(
" {} {} needs the {} bus -- not yet assigned",
"!!".red().bold(),
lib_name.bright_white(),
meta.bus.bright_cyan()
);
match meta.bus.as_str() {
"i2c" => {
println!(
" Wire SDA + SCL, then: {}",
"anvil pin --assign i2c".bright_cyan()
);
println!(
" {}",
"(SDA/SCL are fixed pins on your board -- A4/A5 on Uno)"
.bright_black()
);
}
"spi" => {
println!(
" Wire MOSI/MISO/SCK + CS, then: {}",
"anvil pin --assign spi --cs 10".bright_cyan()
);
println!(
" {}",
"(MOSI/MISO/SCK are fixed. You pick the CS pin.)"
.bright_black()
);
}
_ => {}
}
}
continue;
}
let unassigned = library::unassigned_pins(&meta, &assigned_names);
if !unassigned.is_empty() {
if !missing_any {
println!(" {}", "Library Wiring:".bright_yellow().bold());
missing_any = true;
}
for assign_name in &unassigned {
println!(
" {} {} needs {} pin \"{}\" -- not yet assigned",
"!!".red().bold(),
lib_name.bright_white(),
meta.bus.bright_cyan(),
assign_name.bright_white()
);
match meta.bus.as_str() {
"analog" => {
println!(
" Wire the sensor to an analog pin (e.g. A0), then:",
);
println!(
" {}",
format!(
"anvil pin --assign {} A0 --mode analog",
assign_name
).bright_cyan()
);
println!(
" {}",
"Any analog pin works (A0-A5 on Uno). Match wire to code."
.bright_black()
);
}
"digital" => {
println!(
" Wire the sensor to a digital pin, then:",
);
println!(
" {}",
format!(
"anvil pin --assign {} <PIN> --mode input",
assign_name
).bright_cyan()
);
}
_ => {
println!(
" {}",
format!(
"anvil pin --assign {} <PIN>",
assign_name
).bright_cyan()
);
}
}
}
}
}
}
if missing_any {
println!();
}
missing_any
}
// =========================================================================
// Init pins from another board
// =========================================================================

View File

@@ -3,3 +3,4 @@ pub mod commands;
pub mod project;
pub mod board;
pub mod templates;
pub mod library;

358
src/library/mod.rs Normal file
View File

@@ -0,0 +1,358 @@
use include_dir::{include_dir, Dir};
use std::path::Path;
use std::fs;
use anyhow::{Result, Context};
static LIBRARY_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/libraries");
/// Metadata parsed from a library's library.toml.
#[derive(Debug, Clone)]
pub struct LibraryMeta {
pub name: String,
pub version: String,
pub description: String,
pub bus: String,
pub pins: Vec<String>,
pub interface: String,
pub implementation: String,
pub mock: String,
pub simulation: Option<String>,
pub test: Option<String>,
}
impl LibraryMeta {
/// Human-readable wiring requirement (e.g. "1 analog pin (e.g. A0)").
pub fn wiring_summary(&self) -> String {
match self.bus.as_str() {
"analog" => format!(
"{} analog pin{} (e.g. A0)",
self.pins.len(),
if self.pins.len() == 1 { "" } else { "s" }
),
"i2c" => "I2C bus (SDA + SCL)".to_string(),
"spi" => "SPI bus (MOSI + MISO + SCK + CS)".to_string(),
"digital" => format!(
"{} digital pin{}",
self.pins.len(),
if self.pins.len() == 1 { "" } else { "s" }
),
_ => format!("{} pin(s)", self.pins.len()),
}
}
/// Default pin mode for assignments derived from bus type.
pub fn default_mode(&self) -> &str {
match self.bus.as_str() {
"analog" => "analog",
"digital" => "input",
_ => "input",
}
}
/// Generate the pin assignment name for a given role.
/// e.g. tmp36 + "data" -> "tmp36_data"
pub fn pin_assignment_name(&self, role: &str) -> String {
format!("{}_{}", self.name, role)
}
/// Return all pin roles with their assignment names.
/// e.g. [("data", "tmp36_data")]
pub fn pin_roles(&self) -> Vec<(&str, String)> {
self.pins.iter()
.map(|role| (role.as_str(), self.pin_assignment_name(role)))
.collect()
}
}
/// List all available libraries embedded in Anvil.
pub fn list_available() -> Vec<LibraryMeta> {
let mut libs = Vec::new();
for dir in LIBRARY_DIR.dirs() {
if let Some(meta) = parse_library_dir(dir) {
libs.push(meta);
}
}
libs.sort_by(|a, b| a.name.cmp(&b.name));
libs
}
/// Look up a specific library by name.
pub fn find_library(name: &str) -> Option<LibraryMeta> {
list_available().into_iter().find(|lib| lib.name == name)
}
/// Extract a library's source files into a project.
/// Header files go to lib/drivers/NAME/, test files go to test/.
pub fn extract_library(name: &str, project_root: &Path) -> Result<Vec<String>> {
let lib_dir = LIBRARY_DIR.get_dir(name)
.ok_or_else(|| anyhow::anyhow!("Library '{}' not found in registry", name))?;
let src_dir = lib_dir.get_dir(&format!("{}/src", name))
.or_else(|| lib_dir.get_dir("src"))
.ok_or_else(|| anyhow::anyhow!("Library '{}' has no src/ directory", name))?;
let dest_dir = project_root.join("lib").join("drivers").join(name);
fs::create_dir_all(&dest_dir)
.context(format!("Failed to create {}", dest_dir.display()))?;
let test_dir = project_root.join("test");
// Ensure test dir exists for library test files
if src_dir.files().any(|f| {
f.path().file_name()
.map(|n| {
let s = n.to_string_lossy();
s.starts_with("test_") && s.ends_with(".cpp")
})
.unwrap_or(false)
}) {
fs::create_dir_all(&test_dir)
.context("Failed to create test directory")?;
}
let mut written = Vec::new();
for file in src_dir.files() {
let file_name = file.path().file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if file_name.is_empty() {
continue;
}
// Test files go into test/ directory, everything else into lib/drivers/NAME/
if file_name.starts_with("test_") && file_name.ends_with(".cpp") {
let dest_path = test_dir.join(&file_name);
fs::write(&dest_path, file.contents())
.context(format!("Failed to write {}", dest_path.display()))?;
written.push(format!("test/{}", file_name));
} else {
let dest_path = dest_dir.join(&file_name);
fs::write(&dest_path, file.contents())
.context(format!("Failed to write {}", dest_path.display()))?;
written.push(format!("lib/drivers/{}/{}", name, file_name));
}
}
Ok(written)
}
/// Remove a library's files from a project.
pub fn remove_library_files(name: &str, project_root: &Path) -> Result<()> {
let dir = project_root.join("lib").join("drivers").join(name);
if dir.exists() {
fs::remove_dir_all(&dir)
.context(format!("Failed to remove {}", dir.display()))?;
}
// Remove the library's test file from test/ if present
let test_file = project_root.join("test").join(format!("test_{}.cpp", name));
if test_file.exists() {
fs::remove_file(&test_file)
.context(format!("Failed to remove {}", test_file.display()))?;
}
// Clean up empty drivers/ directory
let drivers_dir = project_root.join("lib").join("drivers");
if drivers_dir.exists() {
if fs::read_dir(&drivers_dir)?.next().is_none() {
fs::remove_dir(&drivers_dir).ok();
}
}
Ok(())
}
/// Check if a library is installed in a project (files exist on disk).
pub fn is_installed_on_disk(name: &str, project_root: &Path) -> bool {
project_root.join("lib").join("drivers").join(name).is_dir()
}
/// Return library pin roles that are not yet assigned in the config.
/// Takes a set of assignment names from the board's pin config.
pub fn unassigned_pins(meta: &LibraryMeta, assigned_names: &[String]) -> Vec<String> {
meta.pin_roles()
.into_iter()
.filter(|(_role, assign_name)| !assigned_names.contains(assign_name))
.map(|(_role, assign_name)| assign_name)
.collect()
}
/// Parse a library.toml from an embedded directory.
fn parse_library_dir(dir: &Dir<'_>) -> Option<LibraryMeta> {
// The include_dir structure nests: libraries/tmp36/library.toml
// but dir.get_file() uses relative paths from the dir root.
let toml_file = dir.files().find(|f| {
f.path().file_name()
.map(|n| n.to_string_lossy() == "library.toml")
.unwrap_or(false)
})?;
let content = std::str::from_utf8(toml_file.contents()).ok()?;
let table: toml::Table = content.parse().ok()?;
let lib = table.get("library")?.as_table()?;
let requires = table.get("requires")?.as_table()?;
let provides = table.get("provides")?.as_table()?;
let pins = requires.get("pins")?
.as_array()?
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
Some(LibraryMeta {
name: lib.get("name")?.as_str()?.to_string(),
version: lib.get("version")?.as_str()?.to_string(),
description: lib.get("description")?.as_str()?.to_string(),
bus: requires.get("bus")?.as_str()?.to_string(),
pins,
interface: provides.get("interface")?.as_str()?.to_string(),
implementation: provides.get("implementation")?.as_str()?.to_string(),
mock: provides.get("mock")?.as_str()?.to_string(),
simulation: provides.get("simulation")
.and_then(|v| v.as_str())
.map(String::from),
test: provides.get("test")
.and_then(|v| v.as_str())
.map(String::from),
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_list_available_includes_tmp36() {
let libs = list_available();
assert!(
libs.iter().any(|l| l.name == "tmp36"),
"Should include tmp36 library"
);
}
#[test]
fn test_find_library_tmp36() {
let meta = find_library("tmp36").expect("tmp36 should exist");
assert_eq!(meta.name, "tmp36");
assert_eq!(meta.bus, "analog");
assert_eq!(meta.pins, vec!["data"]);
assert_eq!(meta.interface, "tmp36.h");
assert_eq!(meta.implementation, "tmp36_analog.h");
assert_eq!(meta.mock, "tmp36_mock.h");
assert!(meta.simulation.is_some());
assert_eq!(meta.test.as_deref(), Some("test_tmp36.cpp"));
}
#[test]
fn test_find_library_nonexistent() {
assert!(find_library("nonexistent").is_none());
}
#[test]
fn test_extract_library_creates_files() {
let tmp = TempDir::new().unwrap();
let written = extract_library("tmp36", tmp.path()).unwrap();
assert!(!written.is_empty(), "Should write at least one file");
// Headers in lib/drivers/tmp36/
let driver_dir = tmp.path().join("lib/drivers/tmp36");
assert!(driver_dir.exists(), "Driver directory should exist");
assert!(driver_dir.join("tmp36.h").exists(), "Interface should exist");
assert!(driver_dir.join("tmp36_analog.h").exists(), "Implementation should exist");
assert!(driver_dir.join("tmp36_mock.h").exists(), "Mock should exist");
assert!(driver_dir.join("tmp36_sim.h").exists(), "Simulation should exist");
// Test file in test/
assert!(
tmp.path().join("test/test_tmp36.cpp").exists(),
"Test file should be in test/ directory"
);
assert!(
written.iter().any(|w| w == "test/test_tmp36.cpp"),
"Written list should include test file"
);
}
#[test]
fn test_remove_library_cleans_up() {
let tmp = TempDir::new().unwrap();
extract_library("tmp36", tmp.path()).unwrap();
assert!(is_installed_on_disk("tmp36", tmp.path()));
assert!(tmp.path().join("test/test_tmp36.cpp").exists());
remove_library_files("tmp36", tmp.path()).unwrap();
assert!(!is_installed_on_disk("tmp36", tmp.path()));
assert!(!tmp.path().join("test/test_tmp36.cpp").exists());
}
#[test]
fn test_extract_library_files_are_ascii() {
let tmp = TempDir::new().unwrap();
extract_library("tmp36", tmp.path()).unwrap();
let driver_dir = tmp.path().join("lib/drivers/tmp36");
for entry in fs::read_dir(&driver_dir).unwrap() {
let entry = entry.unwrap();
let content = fs::read_to_string(entry.path()).unwrap();
for (line_num, line) in content.lines().enumerate() {
for (col, ch) in line.chars().enumerate() {
assert!(
ch.is_ascii(),
"Non-ASCII in {} at {}:{}: U+{:04X}",
entry.file_name().to_string_lossy(),
line_num + 1, col + 1, ch as u32
);
}
}
}
}
#[test]
fn test_wiring_summary_analog() {
let meta = find_library("tmp36").unwrap();
let summary = meta.wiring_summary();
assert!(summary.contains("analog"), "Should mention analog");
assert!(summary.contains("A0"), "Should suggest A0 as example");
}
#[test]
fn test_pin_assignment_name() {
let meta = find_library("tmp36").unwrap();
assert_eq!(meta.pin_assignment_name("data"), "tmp36_data");
}
#[test]
fn test_pin_roles() {
let meta = find_library("tmp36").unwrap();
let roles = meta.pin_roles();
assert_eq!(roles.len(), 1);
assert_eq!(roles[0].0, "data");
assert_eq!(roles[0].1, "tmp36_data");
}
#[test]
fn test_default_mode_analog() {
let meta = find_library("tmp36").unwrap();
assert_eq!(meta.default_mode(), "analog");
}
#[test]
fn test_unassigned_pins_all_missing() {
let meta = find_library("tmp36").unwrap();
let assigned: Vec<String> = vec![];
let missing = unassigned_pins(&meta, &assigned);
assert_eq!(missing, vec!["tmp36_data"]);
}
#[test]
fn test_unassigned_pins_all_assigned() {
let meta = find_library("tmp36").unwrap();
let assigned = vec!["tmp36_data".to_string()];
let missing = unassigned_pins(&meta, &assigned);
assert!(missing.is_empty());
}
}

View File

@@ -120,6 +120,41 @@ enum Commands {
dir: Option<String>,
},
/// Add a device library to the project
Add {
/// Library name (e.g. tmp36)
name: String,
/// Assign a pin during add (e.g. --pin A0)
#[arg(long, value_name = "PIN")]
pin: Option<String>,
/// Path to project directory (defaults to current directory)
#[arg(long, short = 'd', value_name = "DIR")]
dir: Option<String>,
},
/// Remove a device library from the project
Remove {
/// Library name (e.g. tmp36)
name: String,
/// Path to project directory (defaults to current directory)
#[arg(long, short = 'd', value_name = "DIR")]
dir: Option<String>,
},
/// List installed or available device libraries
Lib {
/// Show all available libraries (not just installed)
#[arg(long)]
available: bool,
/// Path to project directory (defaults to current directory)
#[arg(long, short = 'd', value_name = "DIR")]
dir: Option<String>,
},
/// View pin maps, assign pins, and audit wiring
Pin {
/// Capability filter (pwm, analog, spi, i2c, uart, interrupt)
@@ -279,6 +314,19 @@ fn main() -> Result<()> {
commands::board::list_boards(dir.as_deref())
}
}
Commands::Add { name, pin, dir } => {
commands::lib::add_library(&name, pin.as_deref(), dir.as_deref())
}
Commands::Remove { name, dir } => {
commands::lib::remove_library(&name, dir.as_deref())
}
Commands::Lib { available, dir } => {
if available {
commands::lib::list_available()
} else {
commands::lib::list_libraries(dir.as_deref())
}
}
Commands::Pin {
name, pin, assign, remove, audit, brief,
generate, capabilities, init_from, mode, cs, board, dir,

View File

@@ -17,6 +17,8 @@ pub struct ProjectConfig {
pub boards: HashMap<String, BoardProfile>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub pins: HashMap<String, BoardPinConfig>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub libraries: HashMap<String, String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -105,6 +107,7 @@ impl ProjectConfig {
},
boards,
pins: HashMap::new(),
libraries: HashMap::new(),
}
}