diff --git a/libraries/tmp36/library.toml b/libraries/tmp36/library.toml new file mode 100644 index 0000000..fd01963 --- /dev/null +++ b/libraries/tmp36/library.toml @@ -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" \ No newline at end of file diff --git a/libraries/tmp36/src/test_tmp36.cpp b/libraries/tmp36/src/test_tmp36.cpp new file mode 100644 index 0000000..2a2427c --- /dev/null +++ b/libraries/tmp36/src/test_tmp36.cpp @@ -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 +#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); + } +} \ No newline at end of file diff --git a/libraries/tmp36/src/tmp36.h b/libraries/tmp36/src/tmp36.h new file mode 100644 index 0000000..fa15ddb --- /dev/null +++ b/libraries/tmp36/src/tmp36.h @@ -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 \ No newline at end of file diff --git a/libraries/tmp36/src/tmp36_analog.h b/libraries/tmp36/src/tmp36_analog.h new file mode 100644 index 0000000..b4fc09c --- /dev/null +++ b/libraries/tmp36/src/tmp36_analog.h @@ -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 \ No newline at end of file diff --git a/libraries/tmp36/src/tmp36_mock.h b/libraries/tmp36/src/tmp36_mock.h new file mode 100644 index 0000000..b518af2 --- /dev/null +++ b/libraries/tmp36/src/tmp36_mock.h @@ -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 \ No newline at end of file diff --git a/libraries/tmp36/src/tmp36_sim.h b/libraries/tmp36/src/tmp36_sim.h new file mode 100644 index 0000000..637d1d5 --- /dev/null +++ b/libraries/tmp36/src/tmp36_sim.h @@ -0,0 +1,74 @@ +#ifndef TMP36_SIM_H +#define TMP36_SIM_H + +#include "tmp36.h" + +#include + +/* + * 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 \ No newline at end of file diff --git a/src/commands/lib.rs b/src/commands/lib.rs new file mode 100644 index 0000000..fe3b894 --- /dev/null +++ b/src/commands/lib.rs @@ -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 = 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 {} --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 {} --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 {} {}", + 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::()); + 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"); + } +} \ No newline at end of file diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 6e6a686..f0df631 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,4 +4,5 @@ pub mod setup; pub mod devices; pub mod refresh; pub mod board; -pub mod pin; \ No newline at end of file +pub mod pin; +pub mod lib; \ No newline at end of file diff --git a/src/commands/pin.rs b/src/commands/pin.rs index 84881c4..d08c991 100644 --- a/src/commands/pin.rs +++ b/src/commands/pin.rs @@ -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 = 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 {} --mode input", + assign_name + ).bright_cyan() + ); + } + _ => { + println!( + " {}", + format!( + "anvil pin --assign {} ", + assign_name + ).bright_cyan() + ); + } + } + } + } + } + } + + if missing_any { + println!(); + } + + missing_any +} + // ========================================================================= // Init pins from another board // ========================================================================= diff --git a/src/lib.rs b/src/lib.rs index fa6409f..9ae9952 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,3 +3,4 @@ pub mod commands; pub mod project; pub mod board; pub mod templates; +pub mod library; \ No newline at end of file diff --git a/src/library/mod.rs b/src/library/mod.rs new file mode 100644 index 0000000..7c5fc95 --- /dev/null +++ b/src/library/mod.rs @@ -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, + pub interface: String, + pub implementation: String, + pub mock: String, + pub simulation: Option, + pub test: Option, +} + +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 { + 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 { + 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> { + 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 { + 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 { + // 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 = 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()); + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 73586bb..93cb7cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -120,6 +120,41 @@ enum Commands { dir: Option, }, + /// 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, + + /// Path to project directory (defaults to current directory) + #[arg(long, short = 'd', value_name = "DIR")] + dir: Option, + }, + + /// 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, + }, + + /// 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, + }, + /// 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, diff --git a/src/project/config.rs b/src/project/config.rs index 8d4b775..a31883e 100644 --- a/src/project/config.rs +++ b/src/project/config.rs @@ -17,6 +17,8 @@ pub struct ProjectConfig { pub boards: HashMap, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub pins: HashMap, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub libraries: HashMap, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -105,6 +107,7 @@ impl ProjectConfig { }, boards, pins: HashMap::new(), + libraries: HashMap::new(), } } diff --git a/templates/basic/test/CMakeLists.txt.tmpl b/templates/basic/test/CMakeLists.txt.tmpl index 9069cd0..ae85bec 100644 --- a/templates/basic/test/CMakeLists.txt.tmpl +++ b/templates/basic/test/CMakeLists.txt.tmpl @@ -29,6 +29,16 @@ include_directories( ${CMAKE_SOURCE_DIR}/mocks ) +# Auto-discover driver libraries (added by: anvil add ) +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 # -------------------------------------------------------------------------- @@ -70,3 +80,21 @@ target_link_libraries(test_system include(GoogleTest) gtest_discover_tests(test_unit) gtest_discover_tests(test_system) + +# -------------------------------------------------------------------------- +# Driver tests (added automatically by: anvil add ) +# -------------------------------------------------------------------------- +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() diff --git a/tests/integration_test.rs b/tests/integration_test.rs index c326328..aef692b 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -6,6 +6,8 @@ use anvil::templates::{TemplateManager, TemplateContext}; use anvil::project::config::{ ProjectConfig, BoardProfile, CONFIG_FILENAME, set_default_in_file, }; +use anvil::library; +use anvil::commands; // ============================================================================ // 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 A0"), "Should define A0"); - // Core Arduino functions - assert!(header.contains("void pinMode("), "Should declare pinMode"); - assert!(header.contains("void digitalWrite("), "Should declare digitalWrite"); - assert!(header.contains("int digitalRead("), "Should declare digitalRead"); - assert!(header.contains("int analogRead("), "Should declare analogRead"); - assert!(header.contains("void analogWrite("), "Should declare analogWrite"); - assert!(header.contains("unsigned long millis()"), "Should declare millis"); - assert!(header.contains("void delay("), "Should declare delay"); + // Core Arduino functions (declarations use aligned whitespace) + assert!(header.contains("pinMode("), "Should declare pinMode"); + assert!(header.contains("digitalWrite("), "Should declare digitalWrite"); + assert!(header.contains("digitalRead("), "Should declare digitalRead"); + assert!(header.contains("analogRead("), "Should declare analogRead"); + assert!(header.contains("analogWrite("), "Should declare analogWrite"); + assert!(header.contains("millis()"), "Should declare millis"); + assert!(header.contains("delay("), "Should declare delay"); // Serial class 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("--unit"), "test.bat should support --unit 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 = 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 = 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 = 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 = 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" + ); } \ No newline at end of file