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

View File

@@ -0,0 +1,15 @@
[library]
name = "tmp36"
version = "0.1.0"
description = "TMP36 analog temperature sensor"
[requires]
bus = "analog"
pins = ["data"]
[provides]
interface = "tmp36.h"
implementation = "tmp36_analog.h"
mock = "tmp36_mock.h"
simulation = "tmp36_sim.h"
test = "test_tmp36.cpp"

View File

@@ -0,0 +1,252 @@
/*
* TMP36 Driver Tests
*
* Auto-generated by: anvil add tmp36
* These tests verify the TMP36 driver mock and simulation without
* any hardware. They run alongside your unit and system tests.
*
* To run: ./test.sh (all tests)
* ./test.sh --system (skips these -- use no filter to include)
* ./test.sh --unit (skips these -- use no filter to include)
*/
#include <gtest/gtest.h>
#include "mock_arduino.h"
#include "sim_hal.h"
#include "tmp36.h"
#include "tmp36_analog.h"
#include "tmp36_mock.h"
#include "tmp36_sim.h"
// ---------------------------------------------------------------------------
// Mock: basic functionality
// ---------------------------------------------------------------------------
class Tmp36MockTest : public ::testing::Test {
protected:
void SetUp() override {
mock_arduino_reset();
}
Tmp36Mock sensor;
};
TEST_F(Tmp36MockTest, DefaultsToRoomTemperature) {
float temp = sensor.readCelsius();
EXPECT_NEAR(temp, 22.0f, 0.01f);
}
TEST_F(Tmp36MockTest, SetTemperatureReturnsExactValue) {
sensor.setTemperature(37.5f);
EXPECT_FLOAT_EQ(sensor.readCelsius(), 37.5f);
}
TEST_F(Tmp36MockTest, FahrenheitConversion) {
sensor.setTemperature(0.0f);
EXPECT_NEAR(sensor.readFahrenheit(), 32.0f, 0.01f);
sensor.setTemperature(100.0f);
EXPECT_NEAR(sensor.readFahrenheit(), 212.0f, 0.01f);
}
TEST_F(Tmp36MockTest, BoilingPointConversion) {
sensor.setTemperature(100.0f);
EXPECT_NEAR(sensor.readFahrenheit(), 212.0f, 0.01f);
}
TEST_F(Tmp36MockTest, NegativeTemperature) {
sensor.setTemperature(-10.0f);
EXPECT_FLOAT_EQ(sensor.readCelsius(), -10.0f);
}
TEST_F(Tmp36MockTest, ReadCountTracking) {
EXPECT_EQ(sensor.readCount(), 0);
sensor.readCelsius();
sensor.readCelsius();
sensor.readRaw();
EXPECT_EQ(sensor.readCount(), 3);
}
TEST_F(Tmp36MockTest, ReadCountReset) {
sensor.readCelsius();
sensor.readCelsius();
sensor.resetReadCount();
EXPECT_EQ(sensor.readCount(), 0);
}
TEST_F(Tmp36MockTest, SetRawReturnsExactValue) {
sensor.setRaw(512);
EXPECT_EQ(sensor.readRaw(), 512);
}
// ---------------------------------------------------------------------------
// Analog: real implementation via SimHal
// ---------------------------------------------------------------------------
class Tmp36AnalogTest : public ::testing::Test {
protected:
void SetUp() override {
mock_arduino_reset();
}
SimHal hal;
};
TEST_F(Tmp36AnalogTest, RoomTemperatureReading) {
// 22 C -> 720 mV -> raw = 720 * 1024 / 5000 = 147.5 -> ~148
Tmp36Analog sensor(&hal, A0, 5.0f);
hal.setAnalog(A0, 148);
float temp = sensor.readCelsius();
// 148 * 5000/1024 = 722.6 mV -> (722.6 - 500) / 10 = 22.26 C
EXPECT_NEAR(temp, 22.26f, 0.1f);
}
TEST_F(Tmp36AnalogTest, FreezingPoint) {
// 0 C -> 500 mV -> raw = 500 * 1024 / 5000 = 102.4 -> ~102
Tmp36Analog sensor(&hal, A0, 5.0f);
hal.setAnalog(A0, 102);
float temp = sensor.readCelsius();
EXPECT_NEAR(temp, 0.0f, 1.0f);
}
TEST_F(Tmp36AnalogTest, HighTemperature) {
// 50 C -> 1000 mV -> raw = 1000 * 1024 / 5000 = 204.8 -> ~205
Tmp36Analog sensor(&hal, A0, 5.0f);
hal.setAnalog(A0, 205);
float temp = sensor.readCelsius();
EXPECT_NEAR(temp, 50.0f, 1.0f);
}
TEST_F(Tmp36AnalogTest, ThreeVoltReference) {
// Same raw value, different ref voltage changes the result
Tmp36Analog sensor_5v(&hal, A0, 5.0f);
Tmp36Analog sensor_3v(&hal, A0, 3.3f);
hal.setAnalog(A0, 200);
float temp_5v = sensor_5v.readCelsius();
float temp_3v = sensor_3v.readCelsius();
// 3.3V ref reads higher mV per count, so higher temperature
// Actually the opposite: lower ref means each count = fewer mV
EXPECT_NE(temp_5v, temp_3v);
}
TEST_F(Tmp36AnalogTest, ReadRawReturnsAnalogValue) {
Tmp36Analog sensor(&hal, A0, 5.0f);
hal.setAnalog(A0, 512);
EXPECT_EQ(sensor.readRaw(), 512);
}
TEST_F(Tmp36AnalogTest, DifferentPin) {
// Can use any analog pin
Tmp36Analog sensor(&hal, A2, 5.0f);
hal.setAnalog(A2, 148);
float temp = sensor.readCelsius();
EXPECT_NEAR(temp, 22.26f, 0.1f);
}
// ---------------------------------------------------------------------------
// Simulation: noise and determinism
// ---------------------------------------------------------------------------
class Tmp36SimTest : public ::testing::Test {
protected:
void SetUp() override {
mock_arduino_reset();
}
};
TEST_F(Tmp36SimTest, ReturnsValuesNearBase) {
Tmp36Sim sensor(25.0f, 1.0f);
for (int i = 0; i < 20; ++i) {
float temp = sensor.readCelsius();
EXPECT_GE(temp, 24.0f) << "Reading " << i << " too low";
EXPECT_LE(temp, 26.0f) << "Reading " << i << " too high";
}
}
TEST_F(Tmp36SimTest, ZeroNoiseReturnsExact) {
Tmp36Sim sensor(25.0f, 0.0f);
EXPECT_FLOAT_EQ(sensor.readCelsius(), 25.0f);
EXPECT_FLOAT_EQ(sensor.readCelsius(), 25.0f);
}
TEST_F(Tmp36SimTest, DeterministicWithSameSeed) {
Tmp36Sim s1(25.0f, 1.0f);
Tmp36Sim s2(25.0f, 1.0f);
s1.setSeed(99);
s2.setSeed(99);
for (int i = 0; i < 10; ++i) {
EXPECT_FLOAT_EQ(s1.readCelsius(), s2.readCelsius())
<< "Reading " << i << " should match with same seed";
}
}
TEST_F(Tmp36SimTest, DifferentSeedsDifferentValues) {
Tmp36Sim s1(25.0f, 1.0f);
Tmp36Sim s2(25.0f, 1.0f);
s1.setSeed(1);
s2.setSeed(2);
// At least one reading should differ
bool any_differ = false;
for (int i = 0; i < 10; ++i) {
if (s1.readCelsius() != s2.readCelsius()) {
any_differ = true;
break;
}
}
EXPECT_TRUE(any_differ);
}
TEST_F(Tmp36SimTest, BaseTemperatureChange) {
Tmp36Sim sensor(20.0f, 0.0f);
EXPECT_FLOAT_EQ(sensor.readCelsius(), 20.0f);
sensor.setBaseTemperature(40.0f);
EXPECT_FLOAT_EQ(sensor.readCelsius(), 40.0f);
}
TEST_F(Tmp36SimTest, RawValueMatchesTemperature) {
Tmp36Sim sensor(25.0f, 0.0f);
// 25 C -> 750 mV -> raw = 750 * 1024 / 5000 = 153.6 -> ~153
int raw = sensor.readRaw();
EXPECT_NEAR(raw, 153, 2);
}
// ---------------------------------------------------------------------------
// Polymorphism: all impls work through TempSensor pointer
// ---------------------------------------------------------------------------
TEST(Tmp36PolymorphismTest, AllImplsWorkThroughBasePointer) {
mock_arduino_reset();
SimHal hal;
hal.setAnalog(A0, 148);
Tmp36Analog analog_sensor(&hal, A0, 5.0f);
Tmp36Mock mock_sensor;
Tmp36Sim sim_sensor(22.0f, 0.0f);
mock_sensor.setTemperature(22.0f);
TempSensor* sensors[] = { &analog_sensor, &mock_sensor, &sim_sensor };
for (auto* s : sensors) {
float c = s->readCelsius();
float f = s->readFahrenheit();
int raw = s->readRaw();
EXPECT_GT(c, 10.0f);
EXPECT_LT(c, 40.0f);
EXPECT_GT(f, 50.0f);
EXPECT_GE(raw, 0);
}
}

View File

@@ -0,0 +1,54 @@
#ifndef TMP36_H
#define TMP36_H
/*
* TMP36 Temperature Sensor -- Abstract Interface
*
* This is the contract that your application code depends on. It does
* not know or care whether the temperature comes from a real TMP36
* wired to an analog pin, a test mock with canned values, or a
* simulation with realistic noise.
*
* Three implementations ship with this driver:
* tmp36_analog.h -- Real hardware via Hal::analogRead()
* tmp36_mock.h -- Test mock with programmable values
* tmp36_sim.h -- Simulation with configurable noise
*
* Usage in application code:
* #include "tmp36.h"
*
* class ThermostatApp {
* public:
* ThermostatApp(Hal* hal, TempSensor* sensor)
* : hal_(hal), sensor_(sensor) {}
*
* void update() {
* float temp = sensor_->readCelsius();
* if (temp > threshold_) {
* hal_->digitalWrite(FAN_PIN, HIGH);
* }
* }
* };
*
* Generated by Anvil -- https://github.com/nexus-workshops/anvil
*/
class TempSensor {
public:
virtual ~TempSensor() = default;
/// Read temperature in degrees Celsius.
virtual float readCelsius() = 0;
/// Read temperature in degrees Fahrenheit.
/// Default implementation converts from Celsius.
virtual float readFahrenheit() {
return readCelsius() * 9.0f / 5.0f + 32.0f;
}
/// Read the raw ADC value (0-1023 for 10-bit).
/// Useful for debugging or custom calibration.
virtual int readRaw() = 0;
};
#endif // TMP36_H

View File

@@ -0,0 +1,48 @@
#ifndef TMP36_ANALOG_H
#define TMP36_ANALOG_H
#include "tmp36.h"
#include "hal.h"
/*
* TMP36 -- Real hardware implementation (analog read).
*
* The TMP36 outputs a voltage proportional to temperature:
* - 10 mV per degree Celsius
* - 500 mV offset at 0 C (so 750 mV = 25 C)
* - Range: -40 C to +125 C
*
* Wiring:
* TMP36 pin 1 (left) -> 5V (or 3.3V)
* TMP36 pin 2 (middle) -> Analog input (A0 by default)
* TMP36 pin 3 (right) -> GND
*
* If your board runs at 3.3V, pass 3.3f as ref_voltage.
*/
class Tmp36Analog : public TempSensor {
public:
/// Create a TMP36 sensor on the given analog pin.
/// ref_voltage: board reference voltage (5.0 for Uno, 3.3 for Due/ESP).
Tmp36Analog(Hal* hal, uint8_t pin, float ref_voltage = 5.0f)
: hal_(hal)
, pin_(pin)
, ref_mv_(ref_voltage * 1000.0f)
{}
float readCelsius() override {
int raw = hal_->analogRead(pin_);
float mv = (float)raw * ref_mv_ / 1024.0f;
return (mv - 500.0f) / 10.0f;
}
int readRaw() override {
return hal_->analogRead(pin_);
}
private:
Hal* hal_;
uint8_t pin_;
float ref_mv_;
};
#endif // TMP36_ANALOG_H

View File

@@ -0,0 +1,60 @@
#ifndef TMP36_MOCK_H
#define TMP36_MOCK_H
#include "tmp36.h"
/*
* TMP36 -- Test mock with programmable values.
*
* Use this in tests to control exactly what temperature your
* application sees, without any real hardware.
*
* Example:
* Tmp36Mock sensor;
* sensor.setTemperature(30.0f);
*
* ThermostatApp app(&sim, &sensor);
* app.update();
*
* EXPECT_EQ(sim.getPin(FAN_PIN), HIGH); // fan should be on
*
* You can also track how many times the sensor was read:
* sensor.resetReadCount();
* app.update();
* EXPECT_EQ(sensor.readCount(), 1);
*/
class Tmp36Mock : public TempSensor {
public:
/// Set the temperature that readCelsius() returns.
void setTemperature(float celsius) {
temp_c_ = celsius;
}
/// Set the raw ADC value that readRaw() returns.
void setRaw(int raw) {
raw_ = raw;
}
float readCelsius() override {
++read_count_;
return temp_c_;
}
int readRaw() override {
++read_count_;
return raw_;
}
/// How many times has the sensor been read?
int readCount() const { return read_count_; }
/// Reset the read counter.
void resetReadCount() { read_count_ = 0; }
private:
float temp_c_ = 22.0f; // Default: room temperature
int raw_ = 153; // ~22 C at 5V reference
int read_count_ = 0;
};
#endif // TMP36_MOCK_H

View File

@@ -0,0 +1,74 @@
#ifndef TMP36_SIM_H
#define TMP36_SIM_H
#include "tmp36.h"
#include <cstdlib>
/*
* TMP36 -- Simulation with realistic noise.
*
* Unlike the mock (which returns exact values), the simulator adds
* configurable noise to a base temperature. This is useful for
* system tests that need to verify your code handles real-world
* sensor jitter, such as:
*
* - Averaging/filtering algorithms
* - Hysteresis thresholds (so the fan doesn't chatter on/off)
* - Out-of-range detection
*
* Example:
* Tmp36Sim sensor(22.0f, 0.5f); // 22 C +/- 0.5 C noise
*
* // Each read returns a slightly different value
* float t1 = sensor.readCelsius(); // maybe 22.3
* float t2 = sensor.readCelsius(); // maybe 21.8
*
* // Simulate outdoor temperature swing
* sensor.setBaseTemperature(35.0f);
*/
class Tmp36Sim : public TempSensor {
public:
/// Create a simulated sensor.
/// base_temp: center temperature in Celsius
/// noise: maximum deviation in either direction (Celsius)
Tmp36Sim(float base_temp = 22.0f, float noise = 0.5f)
: base_temp_(base_temp)
, noise_range_(noise)
, seed_(42)
{}
/// Change the base temperature (simulates environment change).
void setBaseTemperature(float celsius) { base_temp_ = celsius; }
/// Change the noise amplitude.
void setNoise(float range) { noise_range_ = range; }
/// Seed the random number generator for repeatable tests.
void setSeed(unsigned int seed) { seed_ = seed; }
float readCelsius() override {
float noise = noise_range_ * (2.0f * nextRandom() - 1.0f);
return base_temp_ + noise;
}
int readRaw() override {
float temp_c = readCelsius();
float mv = temp_c * 10.0f + 500.0f;
return (int)(mv * 1024.0f / 5000.0f);
}
private:
float base_temp_;
float noise_range_;
unsigned int seed_;
/// Simple LCG random in [0.0, 1.0).
/// Deterministic -- same seed gives same sequence.
float nextRandom() {
seed_ = seed_ * 1103515245 + 12345;
return (float)((seed_ >> 16) & 0x7FFF) / 32768.0f;
}
};
#endif // TMP36_SIM_H

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 devices;
pub mod refresh; pub mod refresh;
pub mod board; 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::{ use crate::board::pinmap::{
self, BoardPinMap, ALL_CAPABILITIES, ALL_MODES, self, BoardPinMap, ALL_CAPABILITIES, ALL_MODES,
}; };
use crate::library;
use crate::project::config::{ use crate::project::config::{
ProjectConfig, BoardPinConfig, PinAssignment, BusConfig, CONFIG_FILENAME, ProjectConfig, BoardPinConfig, PinAssignment, BusConfig, CONFIG_FILENAME,
}; };
@@ -393,16 +394,22 @@ pub fn audit_pins(
if pc.assignments.is_empty() && pc.buses.is_empty() { if pc.assignments.is_empty() && pc.buses.is_empty() {
println!(" {}", "No pin assignments configured.".bright_black()); println!(" {}", "No pin assignments configured.".bright_black());
println!(); println!();
println!(" Get started:");
println!( // Even with no manual assignments, libraries may need pins
" {}", let has_library_warnings = print_library_pin_warnings(&config, pc);
format!("anvil pin --assign led 13 --board {}", board).bright_black()
); if !has_library_warnings {
println!( println!(" Get started:");
" {}", println!(
format!("anvil pin --assign i2c --board {}", board).bright_black() " {}",
); format!("anvil pin --assign led 13 --board {}", board).bright_black()
println!(); );
println!(
" {}",
format!("anvil pin --assign i2c --board {}", board).bright_black()
);
println!();
}
return Ok(()); return Ok(());
} }
@@ -512,6 +519,9 @@ pub fn audit_pins(
} }
} }
// Library pin check
print_library_pin_warnings(&config, pc);
// Wiring checklist // Wiring checklist
println!(" {}", "Wiring Checklist:".bold()); println!(" {}", "Wiring Checklist:".bold());
let mut all_wiring: Vec<(u8, String, String)> = Vec::new(); let mut all_wiring: Vec<(u8, String, String)> = Vec::new();
@@ -554,6 +564,126 @@ pub fn audit_pins(
Ok(()) 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 // Init pins from another board
// ========================================================================= // =========================================================================

View File

@@ -3,3 +3,4 @@ pub mod commands;
pub mod project; pub mod project;
pub mod board; pub mod board;
pub mod templates; 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>, 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 /// View pin maps, assign pins, and audit wiring
Pin { Pin {
/// Capability filter (pwm, analog, spi, i2c, uart, interrupt) /// Capability filter (pwm, analog, spi, i2c, uart, interrupt)
@@ -279,6 +314,19 @@ fn main() -> Result<()> {
commands::board::list_boards(dir.as_deref()) 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 { Commands::Pin {
name, pin, assign, remove, audit, brief, name, pin, assign, remove, audit, brief,
generate, capabilities, init_from, mode, cs, board, dir, generate, capabilities, init_from, mode, cs, board, dir,

View File

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

View File

@@ -29,6 +29,16 @@ include_directories(
${CMAKE_SOURCE_DIR}/mocks ${CMAKE_SOURCE_DIR}/mocks
) )
# Auto-discover driver libraries (added by: anvil add <driver>)
if(IS_DIRECTORY ${LIB_DIR}/drivers)
file(GLOB DRIVER_DIRS ${LIB_DIR}/drivers/*)
foreach(DRIVER_DIR ${DRIVER_DIRS})
if(IS_DIRECTORY ${DRIVER_DIR})
include_directories(${DRIVER_DIR})
endif()
endforeach()
endif()
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Mock Arduino library -- provides Arduino API on x86_64 # Mock Arduino library -- provides Arduino API on x86_64
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -70,3 +80,21 @@ target_link_libraries(test_system
include(GoogleTest) include(GoogleTest)
gtest_discover_tests(test_unit) gtest_discover_tests(test_unit)
gtest_discover_tests(test_system) gtest_discover_tests(test_system)
# --------------------------------------------------------------------------
# Driver tests (added automatically by: anvil add <driver>)
# --------------------------------------------------------------------------
file(GLOB DRIVER_TEST_SOURCES ${CMAKE_SOURCE_DIR}/test_*.cpp)
# Exclude test_unit.cpp and test_system.cpp (already handled above)
list(FILTER DRIVER_TEST_SOURCES EXCLUDE REGEX "test_unit\\.cpp$")
list(FILTER DRIVER_TEST_SOURCES EXCLUDE REGEX "test_system\\.cpp$")
foreach(TEST_SOURCE ${DRIVER_TEST_SOURCES})
get_filename_component(TEST_NAME ${TEST_SOURCE} NAME_WE)
add_executable(${TEST_NAME} ${TEST_SOURCE})
target_link_libraries(${TEST_NAME}
GTest::gtest_main
mock_arduino
)
gtest_discover_tests(${TEST_NAME})
endforeach()

View File

@@ -6,6 +6,8 @@ use anvil::templates::{TemplateManager, TemplateContext};
use anvil::project::config::{ use anvil::project::config::{
ProjectConfig, BoardProfile, CONFIG_FILENAME, set_default_in_file, ProjectConfig, BoardProfile, CONFIG_FILENAME, set_default_in_file,
}; };
use anvil::library;
use anvil::commands;
// ============================================================================ // ============================================================================
// Template extraction tests // Template extraction tests
@@ -3309,14 +3311,14 @@ fn test_mock_arduino_header_has_core_api() {
assert!(header.contains("#define LED_BUILTIN"), "Should define LED_BUILTIN"); assert!(header.contains("#define LED_BUILTIN"), "Should define LED_BUILTIN");
assert!(header.contains("#define A0"), "Should define A0"); assert!(header.contains("#define A0"), "Should define A0");
// Core Arduino functions // Core Arduino functions (declarations use aligned whitespace)
assert!(header.contains("void pinMode("), "Should declare pinMode"); assert!(header.contains("pinMode("), "Should declare pinMode");
assert!(header.contains("void digitalWrite("), "Should declare digitalWrite"); assert!(header.contains("digitalWrite("), "Should declare digitalWrite");
assert!(header.contains("int digitalRead("), "Should declare digitalRead"); assert!(header.contains("digitalRead("), "Should declare digitalRead");
assert!(header.contains("int analogRead("), "Should declare analogRead"); assert!(header.contains("analogRead("), "Should declare analogRead");
assert!(header.contains("void analogWrite("), "Should declare analogWrite"); assert!(header.contains("analogWrite("), "Should declare analogWrite");
assert!(header.contains("unsigned long millis()"), "Should declare millis"); assert!(header.contains("millis()"), "Should declare millis");
assert!(header.contains("void delay("), "Should declare delay"); assert!(header.contains("delay("), "Should declare delay");
// Serial class // Serial class
assert!(header.contains("class MockSerial"), "Should declare MockSerial"); assert!(header.contains("class MockSerial"), "Should declare MockSerial");
@@ -3483,4 +3485,613 @@ fn test_root_test_scripts_exist_and_reference_test_dir() {
assert!(test_bat.contains("ctest"), "test.bat should invoke ctest"); assert!(test_bat.contains("ctest"), "test.bat should invoke ctest");
assert!(test_bat.contains("--unit"), "test.bat should support --unit flag"); assert!(test_bat.contains("--unit"), "test.bat should support --unit flag");
assert!(test_bat.contains("--system"), "test.bat should support --system flag"); assert!(test_bat.contains("--system"), "test.bat should support --system flag");
}
// ==========================================================================
// Device Library: anvil add / remove / lib
// ==========================================================================
#[test]
fn test_library_registry_lists_tmp36() {
let libs = library::list_available();
assert!(!libs.is_empty(), "Should have at least one library");
let tmp36 = libs.iter().find(|l| l.name == "tmp36");
assert!(tmp36.is_some(), "TMP36 should be in the registry");
let meta = tmp36.unwrap();
assert_eq!(meta.bus, "analog");
assert_eq!(meta.pins, vec!["data"]);
assert_eq!(meta.interface, "tmp36.h");
assert_eq!(meta.mock, "tmp36_mock.h");
}
#[test]
fn test_library_find_by_name() {
assert!(library::find_library("tmp36").is_some());
assert!(library::find_library("nonexistent_sensor").is_none());
}
#[test]
fn test_library_extract_creates_driver_directory() {
let tmp = TempDir::new().unwrap();
let written = library::extract_library("tmp36", tmp.path()).unwrap();
assert!(!written.is_empty(), "Should write files");
let driver_dir = tmp.path().join("lib/drivers/tmp36");
assert!(driver_dir.exists(), "Driver directory should be created");
// All four files should exist
assert!(driver_dir.join("tmp36.h").exists(), "Interface header");
assert!(driver_dir.join("tmp36_analog.h").exists(), "Implementation");
assert!(driver_dir.join("tmp36_mock.h").exists(), "Mock");
assert!(driver_dir.join("tmp36_sim.h").exists(), "Simulation");
}
#[test]
fn test_library_extract_files_content_is_valid() {
let tmp = TempDir::new().unwrap();
library::extract_library("tmp36", tmp.path()).unwrap();
let driver_dir = tmp.path().join("lib/drivers/tmp36");
// Interface should define TempSensor class
let interface = fs::read_to_string(driver_dir.join("tmp36.h")).unwrap();
assert!(interface.contains("class TempSensor"), "Should define TempSensor");
assert!(interface.contains("readCelsius"), "Should declare readCelsius");
assert!(interface.contains("readFahrenheit"), "Should declare readFahrenheit");
assert!(interface.contains("readRaw"), "Should declare readRaw");
// Implementation should include hal.h
let impl_h = fs::read_to_string(driver_dir.join("tmp36_analog.h")).unwrap();
assert!(impl_h.contains("hal.h"), "Implementation should use HAL");
assert!(impl_h.contains("class Tmp36Analog"), "Should define Tmp36Analog");
assert!(impl_h.contains("analogRead"), "Should use analogRead");
// Mock should have setTemperature
let mock_h = fs::read_to_string(driver_dir.join("tmp36_mock.h")).unwrap();
assert!(mock_h.contains("class Tmp36Mock"), "Should define Tmp36Mock");
assert!(mock_h.contains("setTemperature"), "Mock should have setTemperature");
// Sim should have noise
let sim_h = fs::read_to_string(driver_dir.join("tmp36_sim.h")).unwrap();
assert!(sim_h.contains("class Tmp36Sim"), "Should define Tmp36Sim");
assert!(sim_h.contains("setNoise"), "Sim should have setNoise");
}
#[test]
fn test_library_remove_cleans_up() {
let tmp = TempDir::new().unwrap();
library::extract_library("tmp36", tmp.path()).unwrap();
assert!(library::is_installed_on_disk("tmp36", tmp.path()));
library::remove_library_files("tmp36", tmp.path()).unwrap();
assert!(!library::is_installed_on_disk("tmp36", tmp.path()));
// drivers/ dir should also be cleaned up if empty
assert!(!tmp.path().join("lib/drivers").exists());
}
#[test]
fn test_library_remove_preserves_other_drivers() {
let tmp = TempDir::new().unwrap();
library::extract_library("tmp36", tmp.path()).unwrap();
// Fake a second driver
let other_dir = tmp.path().join("lib/drivers/bmp280");
fs::create_dir_all(&other_dir).unwrap();
fs::write(other_dir.join("bmp280.h"), "// placeholder").unwrap();
library::remove_library_files("tmp36", tmp.path()).unwrap();
// tmp36 gone, bmp280 still there
assert!(!library::is_installed_on_disk("tmp36", tmp.path()));
assert!(other_dir.exists(), "Other driver should survive");
}
#[test]
fn test_library_files_are_ascii_only() {
let tmp = TempDir::new().unwrap();
library::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_config_libraries_field_roundtrips() {
let tmp = TempDir::new().unwrap();
let mut config = ProjectConfig::new("lib_test");
config.libraries.insert("tmp36".to_string(), "0.1.0".to_string());
config.save(tmp.path()).unwrap();
let loaded = ProjectConfig::load(tmp.path()).unwrap();
assert_eq!(loaded.libraries.len(), 1);
assert_eq!(loaded.libraries["tmp36"], "0.1.0");
}
#[test]
fn test_config_empty_libraries_not_serialized() {
let tmp = TempDir::new().unwrap();
let config = ProjectConfig::new("no_libs");
config.save(tmp.path()).unwrap();
let content = fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap();
assert!(
!content.contains("[libraries]"),
"Empty libraries should not appear in TOML"
);
}
#[test]
fn test_config_libraries_serialized_when_present() {
let tmp = TempDir::new().unwrap();
let mut config = ProjectConfig::new("has_libs");
config.libraries.insert("tmp36".to_string(), "0.1.0".to_string());
config.save(tmp.path()).unwrap();
let content = fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap();
assert!(
content.contains("[libraries]"),
"Non-empty libraries should appear in TOML"
);
assert!(content.contains("tmp36"));
}
#[test]
fn test_cmake_autodiscovers_driver_directories() {
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "cmake_drv".to_string(),
anvil_version: "1.0.0".to_string(),
board_name: "uno".to_string(),
fqbn: "arduino:avr:uno".to_string(),
baud: 115200,
};
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
let cmake = fs::read_to_string(tmp.path().join("test/CMakeLists.txt")).unwrap();
assert!(
cmake.contains("drivers"),
"CMakeLists should reference drivers directory"
);
assert!(
cmake.contains("GLOB DRIVER_DIRS"),
"CMakeLists should glob driver directories"
);
assert!(
cmake.contains("include_directories(${DRIVER_DIR})"),
"CMakeLists should add each driver to include path"
);
// Driver test auto-discovery
assert!(
cmake.contains("GLOB DRIVER_TEST_SOURCES"),
"CMakeLists should glob driver test files"
);
assert!(
cmake.contains("gtest_discover_tests(${TEST_NAME})"),
"CMakeLists should register driver tests with CTest"
);
}
// ==========================================================================
// Device Library: end-to-end command-level tests
// ==========================================================================
#[test]
fn test_add_library_full_flow() {
// Simulates: anvil new mocktest && anvil add tmp36
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "e2e_add".to_string(),
anvil_version: "1.0.0".to_string(),
board_name: "uno".to_string(),
fqbn: "arduino:avr:uno".to_string(),
baud: 115200,
};
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
ProjectConfig::new("e2e_add").save(tmp.path()).unwrap();
// Pre-check: no libraries, no drivers dir
let config_before = ProjectConfig::load(tmp.path()).unwrap();
assert!(config_before.libraries.is_empty());
assert!(!tmp.path().join("lib/drivers/tmp36").exists());
// Extract library and update config (mirrors what add_library does)
let meta = library::find_library("tmp36").unwrap();
let written = library::extract_library("tmp36", tmp.path()).unwrap();
assert_eq!(written.len(), 5, "Should write 5 files (4 headers + 1 test)");
let mut config = ProjectConfig::load(tmp.path()).unwrap();
let driver_include = format!("lib/drivers/{}", meta.name);
if !config.build.include_dirs.contains(&driver_include) {
config.build.include_dirs.push(driver_include.clone());
}
config.libraries.insert(meta.name.clone(), meta.version.clone());
config.save(tmp.path()).unwrap();
// Post-check: files exist, config updated
assert!(tmp.path().join("lib/drivers/tmp36/tmp36.h").exists());
assert!(tmp.path().join("lib/drivers/tmp36/tmp36_analog.h").exists());
assert!(tmp.path().join("lib/drivers/tmp36/tmp36_mock.h").exists());
assert!(tmp.path().join("lib/drivers/tmp36/tmp36_sim.h").exists());
assert!(tmp.path().join("test/test_tmp36.cpp").exists(), "Test file in test/");
let config_after = ProjectConfig::load(tmp.path()).unwrap();
assert_eq!(config_after.libraries["tmp36"], "0.1.0");
assert!(
config_after.build.include_dirs.contains(&driver_include),
"include_dirs should contain driver path"
);
}
#[test]
fn test_remove_library_full_flow() {
// Simulates: anvil add tmp36 && anvil remove tmp36
let tmp = TempDir::new().unwrap();
ProjectConfig::new("e2e_rm").save(tmp.path()).unwrap();
// Add
let meta = library::find_library("tmp36").unwrap();
library::extract_library("tmp36", tmp.path()).unwrap();
let mut config = ProjectConfig::load(tmp.path()).unwrap();
let driver_include = format!("lib/drivers/{}", meta.name);
config.build.include_dirs.push(driver_include.clone());
config.libraries.insert(meta.name.clone(), meta.version.clone());
config.save(tmp.path()).unwrap();
// Remove
library::remove_library_files("tmp36", tmp.path()).unwrap();
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.build.include_dirs.retain(|d| d != &driver_include);
config.libraries.remove("tmp36");
config.save(tmp.path()).unwrap();
// Post-check: files gone, config clean
assert!(!tmp.path().join("lib/drivers/tmp36").exists());
assert!(!tmp.path().join("test/test_tmp36.cpp").exists(), "Test file should be removed");
let config_final = ProjectConfig::load(tmp.path()).unwrap();
assert!(config_final.libraries.is_empty());
assert!(
!config_final.build.include_dirs.contains(&driver_include),
"include_dirs should not contain driver path after remove"
);
}
#[test]
fn test_add_remove_readd_idempotent() {
// Simulates: anvil add tmp36 && anvil remove tmp36 && anvil add tmp36
let tmp = TempDir::new().unwrap();
ProjectConfig::new("e2e_idem").save(tmp.path()).unwrap();
let meta = library::find_library("tmp36").unwrap();
let driver_include = format!("lib/drivers/{}", meta.name);
// Add
library::extract_library("tmp36", tmp.path()).unwrap();
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.build.include_dirs.push(driver_include.clone());
config.libraries.insert(meta.name.clone(), meta.version.clone());
config.save(tmp.path()).unwrap();
// Remove
library::remove_library_files("tmp36", tmp.path()).unwrap();
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.build.include_dirs.retain(|d| d != &driver_include);
config.libraries.remove("tmp36");
config.save(tmp.path()).unwrap();
assert!(!tmp.path().join("lib/drivers/tmp36").exists());
// Re-add
library::extract_library("tmp36", tmp.path()).unwrap();
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.build.include_dirs.push(driver_include.clone());
config.libraries.insert(meta.name.clone(), meta.version.clone());
config.save(tmp.path()).unwrap();
// Everything back to normal
assert!(tmp.path().join("lib/drivers/tmp36/tmp36.h").exists());
let config_final = ProjectConfig::load(tmp.path()).unwrap();
assert_eq!(config_final.libraries["tmp36"], "0.1.0");
assert!(config_final.build.include_dirs.contains(&driver_include));
// No duplicate include_dirs
let count = config_final.build.include_dirs.iter()
.filter(|d| *d == &driver_include)
.count();
assert_eq!(count, 1, "Should not duplicate include dir on re-add");
}
#[test]
fn test_library_interface_compiles_against_hal() {
// Verify the actual C++ content is structurally correct:
// tmp36_analog.h includes hal.h, tmp36_mock.h and tmp36_sim.h are standalone
let tmp = TempDir::new().unwrap();
library::extract_library("tmp36", tmp.path()).unwrap();
let driver_dir = tmp.path().join("lib/drivers/tmp36");
let analog = fs::read_to_string(driver_dir.join("tmp36_analog.h")).unwrap();
assert!(analog.contains("#include \"hal.h\""), "Analog impl must include hal.h");
assert!(analog.contains("#include \"tmp36.h\""), "Analog impl must include interface");
assert!(analog.contains("Hal*"), "Analog impl must accept Hal pointer");
let mock = fs::read_to_string(driver_dir.join("tmp36_mock.h")).unwrap();
assert!(!mock.contains("hal.h"), "Mock should NOT depend on hal.h");
assert!(mock.contains("#include \"tmp36.h\""), "Mock must include interface");
let sim = fs::read_to_string(driver_dir.join("tmp36_sim.h")).unwrap();
assert!(!sim.contains("hal.h"), "Sim should NOT depend on hal.h");
assert!(sim.contains("#include \"tmp36.h\""), "Sim must include interface");
}
#[test]
fn test_library_polymorphism_contract() {
// All implementations must inherit from TempSensor
let tmp = TempDir::new().unwrap();
library::extract_library("tmp36", tmp.path()).unwrap();
let driver_dir = tmp.path().join("lib/drivers/tmp36");
let interface = fs::read_to_string(driver_dir.join("tmp36.h")).unwrap();
assert!(interface.contains("class TempSensor"));
assert!(interface.contains("virtual float readCelsius()"));
assert!(interface.contains("virtual int readRaw()"));
// Each impl must extend TempSensor
for (file, class) in [
("tmp36_analog.h", "Tmp36Analog"),
("tmp36_mock.h", "Tmp36Mock"),
("tmp36_sim.h", "Tmp36Sim"),
] {
let content = fs::read_to_string(driver_dir.join(file)).unwrap();
assert!(
content.contains(&format!("class {} : public TempSensor", class)),
"{} should extend TempSensor",
file
);
assert!(
content.contains("readCelsius() override"),
"{} should override readCelsius",
file
);
assert!(
content.contains("readRaw() override"),
"{} should override readRaw",
file
);
}
}
// ==========================================================================
// Device Library: UX helpers and pin integration
// ==========================================================================
#[test]
fn test_library_meta_wiring_summary() {
let meta = library::find_library("tmp36").unwrap();
let summary = meta.wiring_summary();
assert!(summary.contains("analog"), "Should mention analog bus");
assert!(summary.contains("A0"), "Should give A0 as example pin");
}
#[test]
fn test_library_meta_pin_roles() {
let meta = library::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_library_meta_default_mode() {
let meta = library::find_library("tmp36").unwrap();
assert_eq!(meta.default_mode(), "analog");
}
#[test]
fn test_library_unassigned_pins_detects_missing() {
let meta = library::find_library("tmp36").unwrap();
let assigned: Vec<String> = vec![];
let missing = library::unassigned_pins(&meta, &assigned);
assert_eq!(missing, vec!["tmp36_data"], "Should flag tmp36_data as unassigned");
}
#[test]
fn test_library_unassigned_pins_detects_assigned() {
let meta = library::find_library("tmp36").unwrap();
let assigned = vec!["tmp36_data".to_string()];
let missing = library::unassigned_pins(&meta, &assigned);
assert!(missing.is_empty(), "Should detect tmp36_data as assigned");
}
#[test]
fn test_add_with_pin_creates_assignment() {
// Simulates: anvil new test_proj && anvil add tmp36 --pin A0
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "pin_lib".to_string(),
anvil_version: "1.0.0".to_string(),
board_name: "uno".to_string(),
fqbn: "arduino:avr:uno".to_string(),
baud: 115200,
};
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
// Extract library files
let meta = library::find_library("tmp36").unwrap();
library::extract_library("tmp36", tmp.path()).unwrap();
// Update config like add_library does
let mut config = ProjectConfig::load(tmp.path()).unwrap();
let driver_include = format!("lib/drivers/{}", meta.name);
if !config.build.include_dirs.contains(&driver_include) {
config.build.include_dirs.push(driver_include);
}
config.libraries.insert(meta.name.clone(), meta.version.clone());
config.save(tmp.path()).unwrap();
// Simulate --pin A0 by calling assign_pin
let assign_name = meta.pin_assignment_name(&meta.pins[0]);
let dir_str = tmp.path().to_string_lossy().to_string();
commands::pin::assign_pin(
&assign_name, "A0",
Some(meta.default_mode()),
None,
Some(&dir_str),
).unwrap();
// Verify the assignment exists
let config_after = ProjectConfig::load(tmp.path()).unwrap();
let board_pins = config_after.pins.get("uno").unwrap();
assert!(
board_pins.assignments.contains_key("tmp36_data"),
"Should have tmp36_data pin assignment"
);
let assignment = &board_pins.assignments["tmp36_data"];
assert_eq!(assignment.mode, "analog");
// Verify unassigned_pins now returns empty
let assigned: Vec<String> = board_pins.assignments.keys().cloned().collect();
let missing = library::unassigned_pins(&meta, &assigned);
assert!(missing.is_empty(), "All library pins should be assigned after --pin");
}
#[test]
fn test_audit_with_library_missing_pin() {
// anvil add tmp36 without --pin should leave pin unassigned
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "audit_lib".to_string(),
anvil_version: "1.0.0".to_string(),
board_name: "uno".to_string(),
fqbn: "arduino:avr:uno".to_string(),
baud: 115200,
};
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
// Add library to config but no pin assignment
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.libraries.insert("tmp36".to_string(), "0.1.0".to_string());
config.save(tmp.path()).unwrap();
// Check that unassigned_pins detects it
let meta = library::find_library("tmp36").unwrap();
let board_pins = config.pins.get("uno");
let assigned: Vec<String> = board_pins
.map(|bp| bp.assignments.keys().cloned().collect())
.unwrap_or_default();
let missing = library::unassigned_pins(&meta, &assigned);
assert_eq!(missing, vec!["tmp36_data"]);
// The actual audit command must not crash (this was a bug:
// audit used to early-return when no pins assigned, skipping library check)
let dir_str = tmp.path().to_string_lossy().to_string();
commands::pin::audit_pins(None, false, Some(&dir_str)).unwrap();
}
#[test]
fn test_audit_with_library_pin_assigned() {
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "audit_ok".to_string(),
anvil_version: "1.0.0".to_string(),
board_name: "uno".to_string(),
fqbn: "arduino:avr:uno".to_string(),
baud: 115200,
};
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
// Add library + pin assignment
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.libraries.insert("tmp36".to_string(), "0.1.0".to_string());
config.save(tmp.path()).unwrap();
let dir_str = tmp.path().to_string_lossy().to_string();
commands::pin::assign_pin("tmp36_data", "A0", Some("analog"), None, Some(&dir_str)).unwrap();
// Now check data model
let config_after = ProjectConfig::load(tmp.path()).unwrap();
let board_pins = config_after.pins.get("uno").unwrap();
let assigned: Vec<String> = board_pins.assignments.keys().cloned().collect();
let meta = library::find_library("tmp36").unwrap();
let missing = library::unassigned_pins(&meta, &assigned);
assert!(missing.is_empty(), "Pin should be satisfied after assignment");
// And the audit command itself should work with the pin assigned
commands::pin::audit_pins(None, false, Some(&dir_str)).unwrap();
}
#[test]
fn test_audit_no_pins_no_libraries_does_not_crash() {
// Baseline: no pins, no libraries -- audit should still work
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "audit_empty".to_string(),
anvil_version: "1.0.0".to_string(),
board_name: "uno".to_string(),
fqbn: "arduino:avr:uno".to_string(),
baud: 115200,
};
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
let dir_str = tmp.path().to_string_lossy().to_string();
commands::pin::audit_pins(None, false, Some(&dir_str)).unwrap();
}
#[test]
fn test_add_remove_pin_assignment_survives() {
// When we remove a library, the pin assignment should still exist
// (the user might want to reassign it to a different library)
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "pin_survive".to_string(),
anvil_version: "1.0.0".to_string(),
board_name: "uno".to_string(),
fqbn: "arduino:avr:uno".to_string(),
baud: 115200,
};
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
// Add library and assign pin
let meta = library::find_library("tmp36").unwrap();
library::extract_library("tmp36", tmp.path()).unwrap();
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.libraries.insert("tmp36".to_string(), "0.1.0".to_string());
let driver_include = format!("lib/drivers/{}", meta.name);
config.build.include_dirs.push(driver_include.clone());
config.save(tmp.path()).unwrap();
let dir_str = tmp.path().to_string_lossy().to_string();
commands::pin::assign_pin("tmp36_data", "A0", Some("analog"), None, Some(&dir_str)).unwrap();
// Remove library
library::remove_library_files("tmp36", tmp.path()).unwrap();
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.build.include_dirs.retain(|d| d != &driver_include);
config.libraries.remove("tmp36");
config.save(tmp.path()).unwrap();
// Pin assignment should still be there
let config_final = ProjectConfig::load(tmp.path()).unwrap();
let board_pins = config_final.pins.get("uno").unwrap();
assert!(
board_pins.assignments.contains_key("tmp36_data"),
"Pin assignment should survive library removal"
);
} }