From 3298844399896b49f78f9e3613d44a1c12e14ccf Mon Sep 17 00:00:00 2001 From: Eric Ratliff Date: Sun, 15 Feb 2026 11:16:17 -0600 Subject: [PATCH] Anvil v1.0.0 -- Arduino build tool with HAL and test scaffolding Single-binary CLI that scaffolds testable Arduino projects, compiles, uploads, and monitors serial output. Templates embed a hardware abstraction layer, Google Mock infrastructure, and CMake-based host tests so application logic can be verified without hardware. Commands: new, doctor, setup, devices, build, upload, monitor 39 Rust tests (21 unit, 18 integration) Cross-platform: Linux and Windows --- .editorconfig | 24 + .gitattributes | 23 + .gitignore | 22 + Cargo.lock | 987 ++++++++++++++++++++ Cargo.toml | 55 ++ LICENSE | 21 + README.md | 112 +++ anvil_src.zip | Bin 0 -> 50136 bytes clippy.toml | 1 + rustfmt.toml | 3 + src/board/mod.rs | 345 +++++++ src/commands/build.rs | 299 ++++++ src/commands/devices.rs | 67 ++ src/commands/doctor.rs | 237 +++++ src/commands/mod.rs | 6 + src/commands/monitor.rs | 167 ++++ src/commands/new.rs | 252 +++++ src/commands/setup.rs | 163 ++++ src/lib.rs | 5 + src/main.rs | 196 ++++ src/project/config.rs | 226 +++++ src/project/mod.rs | 3 + src/templates/mod.rs | 234 +++++ src/version.rs | 1 + templates/basic/README.md.tmpl | 74 ++ templates/basic/__name__/__name__.ino.tmpl | 28 + templates/basic/_dot_anvil.toml.tmpl | 12 + templates/basic/_dot_clang-format | 8 + templates/basic/_dot_editorconfig | 21 + templates/basic/_dot_gitignore | 10 + templates/basic/_dot_vscode/settings.json | 21 + templates/basic/lib/app/__name___app.h.tmpl | 88 ++ templates/basic/lib/hal/hal.h | 67 ++ templates/basic/lib/hal/hal_arduino.h | 93 ++ templates/basic/test/CMakeLists.txt.tmpl | 47 + templates/basic/test/mocks/mock_hal.h | 45 + templates/basic/test/mocks/sim_hal.h | 256 +++++ templates/basic/test/run_tests.bat | 42 + templates/basic/test/run_tests.sh | 73 ++ templates/basic/test/test_unit.cpp.tmpl | 124 +++ tests/integration_test.rs | 408 ++++++++ 41 files changed, 4866 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 anvil_src.zip create mode 100644 clippy.toml create mode 100644 rustfmt.toml create mode 100644 src/board/mod.rs create mode 100644 src/commands/build.rs create mode 100644 src/commands/devices.rs create mode 100644 src/commands/doctor.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/monitor.rs create mode 100644 src/commands/new.rs create mode 100644 src/commands/setup.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/project/config.rs create mode 100644 src/project/mod.rs create mode 100644 src/templates/mod.rs create mode 100644 src/version.rs create mode 100644 templates/basic/README.md.tmpl create mode 100644 templates/basic/__name__/__name__.ino.tmpl create mode 100644 templates/basic/_dot_anvil.toml.tmpl create mode 100644 templates/basic/_dot_clang-format create mode 100644 templates/basic/_dot_editorconfig create mode 100644 templates/basic/_dot_gitignore create mode 100644 templates/basic/_dot_vscode/settings.json create mode 100644 templates/basic/lib/app/__name___app.h.tmpl create mode 100644 templates/basic/lib/hal/hal.h create mode 100644 templates/basic/lib/hal/hal_arduino.h create mode 100644 templates/basic/test/CMakeLists.txt.tmpl create mode 100644 templates/basic/test/mocks/mock_hal.h create mode 100644 templates/basic/test/mocks/sim_hal.h create mode 100644 templates/basic/test/run_tests.bat create mode 100644 templates/basic/test/run_tests.sh create mode 100644 templates/basic/test/test_unit.cpp.tmpl create mode 100644 tests/integration_test.rs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2648912 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.rs] +indent_size = 4 + +[*.toml] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[*.bat] +end_of_line = crlf diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1812f0d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,23 @@ +# Auto-detect text files and normalize line endings +* text=auto + +# Rust source +*.rs text diff=rust +*.toml text + +# Shell scripts must keep LF even on Windows +*.sh text eol=lf + +# Batch scripts must keep CRLF +*.bat text eol=crlf + +# C/C++ templates +*.h text +*.cpp text +*.ino text + +# Documentation +*.md text diff=markdown + +# Binary / generated -- do not diff +Cargo.lock linguist-generated=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55e0947 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Rust build output +/target/ +**/*.rs.bk + +# Cargo lock is committed for binaries (not libraries) +# Cargo.lock is tracked + +# IDE +.idea/ +*.iml +.vscode/.history/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db +Desktop.ini + +# Test artifacts +/tests/tmp/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..04178eb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,987 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anvil" +version = "1.0.0" +dependencies = [ + "anyhow", + "assert_cmd", + "clap", + "colored", + "ctrlc", + "dirs", + "home", + "include_dir", + "predicates", + "serde", + "serde_json", + "tempfile", + "thiserror", + "toml", + "which", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "assert_cmd" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nix" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.3.2", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "which" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bf3ea8596f3a0dd5980b46430f2058dfe2c36a27ccfbb1845d6fbfcd9ba6e14" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", + "windows-sys 0.48.0", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..05bf241 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "anvil" +version = "1.0.0" +edition = "2021" +authors = ["Eric Ratliff "] +description = "Arduino project generator and build tool - forges clean embedded projects" +license = "MIT" + +[lib] +name = "anvil" +path = "src/lib.rs" + +[[bin]] +name = "anvil" +path = "src/main.rs" + +[dependencies] +# CLI framework +clap = { version = "4.4", features = ["derive", "cargo"] } + +# Filesystem +dirs = "5.0" + +# Configuration +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = "0.8" + +# Embedded templates +include_dir = "0.7" + +# Error handling +anyhow = "1.0" +thiserror = "1.0" + +# Colors +colored = "2.1" + +# Process / which +which = "5.0" +home = "=0.5.9" + +# Signal handling +ctrlc = "3.4" + +[dev-dependencies] +tempfile = "3.13" +assert_cmd = "2.0" +predicates = "3.1" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c72736c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025-2026 Nexus Workshops LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..15835a6 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# Anvil + +**Arduino project generator and build tool -- forges clean embedded projects.** + +A single binary that scaffolds testable Arduino projects with hardware abstraction, +Google Mock infrastructure, and a streamlined build/upload/monitor workflow. Works on +Linux and Windows. + +Anvil is a [Nexus Workshops](https://nxlearn.net) project. + +## Install + +Download the latest release binary for your platform: + +```bash +# Linux +chmod +x anvil +sudo mv anvil /usr/local/bin/ + +# Windows +# Add anvil.exe to a directory in your PATH +``` + +Then run first-time setup: + +```bash +anvil setup +``` + +This checks for `arduino-cli`, installs the `arduino:avr` core, and verifies +your system is ready. + +## Quick Start + +```bash +# Create a new project +anvil new blink + +# Check system health +anvil doctor + +# Find your board +anvil devices + +# Compile, upload, and open serial monitor +cd blink +anvil build --monitor blink + +# Run host-side tests (no board needed) +cd test && ./run_tests.sh +``` + +## Commands + +| Command | Description | +|-------------------|------------------------------------------------| +| `anvil new NAME` | Create a new project with HAL and test scaffold | +| `anvil doctor` | Check system prerequisites | +| `anvil setup` | Install arduino-cli and AVR core | +| `anvil devices` | List connected boards and serial ports | +| `anvil build DIR` | Compile and upload a sketch | +| `anvil upload DIR`| Upload cached build (no recompile) | +| `anvil monitor` | Open serial monitor (`--watch` for persistent) | + +## Project Architecture + +Every Anvil project uses a Hardware Abstraction Layer (HAL): + +``` +your-project/ + your-project/your-project.ino -- entry point + lib/hal/hal.h -- abstract interface + lib/hal/hal_arduino.h -- real hardware (Arduino.h) + lib/app/your-project_app.h -- app logic (testable) + test/mocks/mock_hal.h -- Google Mock HAL + test/test_unit.cpp -- unit tests + .anvil.toml -- project config +``` + +The app code depends on `Hal`, never on `Arduino.h` directly. This means +your application logic compiles and runs on the host for testing. + +## Configuration + +Each project has an `.anvil.toml`: + +```toml +[project] +name = "blink" + +[build] +fqbn = "arduino:avr:uno" +warnings = "more" +include_dirs = ["lib/hal", "lib/app"] +extra_flags = ["-Werror"] + +[monitor] +baud = 115200 +``` + +## Building from Source + +```bash +cargo build --release +``` + +The release binary is at `target/release/anvil` (Linux) or +`target\release\anvil.exe` (Windows). + +## License + +MIT -- see [LICENSE](LICENSE). diff --git a/anvil_src.zip b/anvil_src.zip new file mode 100644 index 0000000000000000000000000000000000000000..86df85723ef973824776367f95068c5336c52c47 GIT binary patch literal 50136 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 literal 0 HcmV?d00001 diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..1306725 --- /dev/null +++ b/clippy.toml @@ -0,0 +1 @@ +msrv = "1.75.0" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..60ba245 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +edition = "2021" +max_width = 100 +use_small_heuristics = "Default" diff --git a/src/board/mod.rs b/src/board/mod.rs new file mode 100644 index 0000000..0d36aa8 --- /dev/null +++ b/src/board/mod.rs @@ -0,0 +1,345 @@ +use anyhow::{Result, bail}; +use colored::*; +use serde::Deserialize; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Information about a detected serial port. +#[derive(Debug, Clone)] +pub struct PortInfo { + pub port_name: String, + pub protocol: String, + pub board_name: String, + pub fqbn: String, +} + +/// JSON schema for `arduino-cli board list --format json` +#[derive(Debug, Deserialize)] +struct BoardListOutput { + #[serde(default)] + detected_ports: Vec, +} + +#[derive(Debug, Deserialize)] +struct DetectedPort { + #[serde(default)] + port: Option, + #[serde(default)] + matching_boards: Option>, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct PortEntry { + #[serde(default)] + address: String, + #[serde(default)] + protocol: String, + #[serde(default)] + protocol_label: String, + #[serde(default)] + properties: Option, +} + +#[derive(Debug, Deserialize)] +struct MatchingBoard { + #[serde(default)] + name: String, + #[serde(default)] + fqbn: String, +} + +/// Enumerate serial ports via `arduino-cli board list --format json`. +/// Falls back to OS-level detection if arduino-cli is unavailable. +pub fn list_ports() -> Vec { + if let Some(cli) = find_arduino_cli() { + if let Ok(ports) = list_ports_via_cli(&cli) { + return ports; + } + } + list_ports_fallback() +} + +fn list_ports_via_cli(cli: &Path) -> Result> { + let output = Command::new(cli) + .args(["board", "list", "--format", "json"]) + .output()?; + + if !output.status.success() { + bail!("arduino-cli board list failed"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: BoardListOutput = serde_json::from_str(&stdout)?; + let mut result = Vec::new(); + + for dp in parsed.detected_ports { + let port = match dp.port { + Some(p) => p, + None => continue, + }; + + if port.protocol != "serial" { + continue; + } + + let (board_name, fqbn) = match dp.matching_boards { + Some(ref boards) if !boards.is_empty() => { + (boards[0].name.clone(), boards[0].fqbn.clone()) + } + _ => ("Unknown".to_string(), String::new()), + }; + + result.push(PortInfo { + port_name: port.address, + protocol: port.protocol_label, + board_name, + fqbn, + }); + } + + Ok(result) +} + +/// Fallback port detection when arduino-cli is not available. +fn list_ports_fallback() -> Vec { + let mut result = Vec::new(); + + #[cfg(unix)] + { + use std::fs; + if let Ok(entries) = fs::read_dir("/dev") { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("ttyUSB") || name.starts_with("ttyACM") { + let path = format!("/dev/{}", name); + let board = if name.starts_with("ttyUSB") { + "Likely CH340/FTDI (run 'anvil setup' for full detection)" + } else { + "Likely Arduino (run 'anvil setup' for full detection)" + }; + result.push(PortInfo { + port_name: path, + protocol: "serial".to_string(), + board_name: board.to_string(), + fqbn: String::new(), + }); + } + } + } + } + + #[cfg(windows)] + { + if let Ok(output) = Command::new("powershell") + .args([ + "-NoProfile", "-Command", + "Get-CimInstance Win32_SerialPort | Select-Object DeviceID,Caption | ConvertTo-Json", + ]) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if let Some(idx) = line.find("COM") { + let port = line[idx..].split('"').next().unwrap_or("").trim(); + if !port.is_empty() { + result.push(PortInfo { + port_name: port.to_string(), + protocol: "serial".to_string(), + board_name: "Detected via WMI".to_string(), + fqbn: String::new(), + }); + } + } + } + } + } + } + + result +} + +/// Auto-detect a single serial port. +pub fn auto_detect_port() -> Result { + let ports = list_ports(); + + if ports.is_empty() { + bail!( + "No serial ports found. Is the board plugged in?\n\ + Run: anvil devices" + ); + } + + if ports.len() == 1 { + return Ok(ports[0].port_name.clone()); + } + + eprintln!("{}", "Multiple serial ports detected:".yellow()); + for p in &ports { + eprintln!(" {} ({})", p.port_name, p.board_name); + } + + // Prefer a port with a recognized board + for p in &ports { + if !p.fqbn.is_empty() { + eprintln!( + "{}", + format!( + "Auto-selected {} ({}). Use -p to override.", + p.port_name, p.board_name + ).yellow() + ); + return Ok(p.port_name.clone()); + } + } + + let selected = ports[0].port_name.clone(); + eprintln!( + "{}", + format!("Auto-selected {}. Use -p to override.", selected).yellow() + ); + Ok(selected) +} + +/// Print detailed port information. +pub fn print_port_details(ports: &[PortInfo]) { + if ports.is_empty() { + println!(" {}", "No serial devices found.".yellow()); + println!(); + println!(" Checklist:"); + println!(" 1. Is the board plugged in via USB?"); + println!(" 2. Is the USB cable a data cable (not charge-only)?"); + #[cfg(target_os = "linux")] + { + println!(" 3. Check kernel log: dmesg | tail -20"); + println!(" 4. Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'"); + } + println!(" 5. Try a different USB port or cable"); + return; + } + + for port in ports { + println!(" {}", port.port_name.green().bold()); + println!(" Board: {}", port.board_name); + if !port.fqbn.is_empty() { + println!(" FQBN: {}", port.fqbn); + } + if !port.protocol.is_empty() { + println!(" Protocol: {}", port.protocol); + } + + #[cfg(unix)] + { + use std::fs::OpenOptions; + let path = std::path::Path::new(&port.port_name); + if path.exists() { + match OpenOptions::new().write(true).open(path) { + Ok(_) => { + println!( + " Permissions: {}", + "OK (writable)".green() + ); + } + Err(_) => { + println!( + " Permissions: {}", + "NOT writable -- run: sudo usermod -aG dialout $USER" + .red() + ); + } + } + } + } + + println!(); + } +} + +/// Find arduino-cli in PATH or in ~/.anvil/bin. +pub fn find_arduino_cli() -> Option { + if let Ok(path) = which::which("arduino-cli") { + return Some(path); + } + + if let Ok(home) = crate::project::config::anvil_home() { + let name = if cfg!(target_os = "windows") { + "arduino-cli.exe" + } else { + "arduino-cli" + }; + let bin = home.join("bin").join(name); + if bin.exists() { + return Some(bin); + } + } + + None +} + +/// Check if the arduino:avr core is installed. +pub fn is_avr_core_installed(cli_path: &Path) -> bool { + let output = Command::new(cli_path) + .args(["core", "list"]) + .output(); + + match output { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout); + stdout.contains("arduino:avr") + } + Err(_) => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_list_ports_does_not_panic() { + let _ports = list_ports(); + } + + #[test] + fn test_port_info_clone() { + let info = PortInfo { + port_name: "/dev/ttyUSB0".to_string(), + protocol: "serial".to_string(), + board_name: "Test".to_string(), + fqbn: "arduino:avr:uno".to_string(), + }; + let cloned = info.clone(); + assert_eq!(cloned.port_name, info.port_name); + } + + #[test] + fn test_parse_empty_board_list() { + let json = r#"{"detected_ports": []}"#; + let parsed: BoardListOutput = serde_json::from_str(json).unwrap(); + assert!(parsed.detected_ports.is_empty()); + } + + #[test] + fn test_parse_board_list_with_port() { + let json = r#"{ + "detected_ports": [{ + "port": { + "address": "/dev/ttyUSB0", + "protocol": "serial", + "protocol_label": "Serial Port (USB)" + }, + "matching_boards": [{ + "name": "Arduino Uno", + "fqbn": "arduino:avr:uno" + }] + }] + }"#; + let parsed: BoardListOutput = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.detected_ports.len(), 1); + let dp = &parsed.detected_ports[0]; + assert_eq!(dp.port.as_ref().unwrap().address, "/dev/ttyUSB0"); + let boards = dp.matching_boards.as_ref().unwrap(); + assert_eq!(boards[0].name, "Arduino Uno"); + } +} diff --git a/src/commands/build.rs b/src/commands/build.rs new file mode 100644 index 0000000..48032bf --- /dev/null +++ b/src/commands/build.rs @@ -0,0 +1,299 @@ +use anyhow::{Result, bail, Context}; +use colored::*; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::board; +use crate::project::config::{ProjectConfig, build_cache_dir}; + +/// Full build: compile + upload (+ optional monitor). +pub fn run_build( + sketch: &str, + verify_only: bool, + do_monitor: bool, + do_clean: bool, + verbose: bool, + port: Option<&str>, + baud: Option, + fqbn_override: Option<&str>, +) -> Result<()> { + let sketch_path = resolve_sketch(sketch)?; + let sketch_name = sketch_name(&sketch_path)?; + let project_root = ProjectConfig::find_project_root(&sketch_path) + .ok(); + + // Load project config if available, otherwise use defaults + let config = match &project_root { + Some(root) => ProjectConfig::load(root)?, + None => { + eprintln!( + "{}", + "No .anvil.toml found; using default settings.".yellow() + ); + ProjectConfig::default() + } + }; + + let fqbn = fqbn_override.unwrap_or(&config.build.fqbn); + let monitor_baud = baud.unwrap_or(config.monitor.baud); + + println!("Sketch: {}", sketch_name.bright_white().bold()); + println!("Board: {}", fqbn.bright_white()); + + // Locate arduino-cli + let cli = board::find_arduino_cli() + .context("arduino-cli not found. Run: anvil setup")?; + + // Verify AVR core + if !board::is_avr_core_installed(&cli) { + bail!("arduino:avr core not installed. Run: anvil setup"); + } + + // Build cache directory + let cache_dir = build_cache_dir()?.join(&sketch_name); + + // Clean if requested + if do_clean && cache_dir.exists() { + println!("{}", "Cleaning build cache...".bright_yellow()); + std::fs::remove_dir_all(&cache_dir)?; + println!(" {} Cache cleared.", "ok".green()); + } + + // Compile + println!("{}", "Compiling...".bright_yellow()); + std::fs::create_dir_all(&cache_dir)?; + + let mut compile_args: Vec = vec![ + "compile".to_string(), + "--fqbn".to_string(), + fqbn.to_string(), + "--build-path".to_string(), + cache_dir.display().to_string(), + "--warnings".to_string(), + config.build.warnings.clone(), + ]; + + if verbose { + compile_args.push("--verbose".to_string()); + } + + // Inject project-level build flags (include paths, -Werror, etc.) + if let Some(ref root) = project_root { + let extra = config.extra_flags_string(root); + if !extra.is_empty() { + compile_args.push("--build-property".to_string()); + compile_args.push(format!("build.extra_flags={}", extra)); + } + } + + compile_args.push(sketch_path.display().to_string()); + + let status = Command::new(&cli) + .args(&compile_args) + .status() + .context("Failed to execute arduino-cli compile")?; + + if !status.success() { + bail!("Compilation failed."); + } + println!(" {} Compile succeeded.", "ok".green()); + + // Report binary size + report_binary_size(&cache_dir, &sketch_name); + + // Verify-only: stop here + if verify_only { + println!(); + println!(" {} Verify-only mode. Done.", "ok".green()); + return Ok(()); + } + + // Upload + let port = match port { + Some(p) => p.to_string(), + None => board::auto_detect_port()?, + }; + + upload_to_board(&cli, fqbn, &port, &cache_dir, verbose)?; + + // Monitor + if do_monitor { + println!(); + println!( + "Opening serial monitor on {} at {} baud...", + port.bright_white(), + monitor_baud + ); + println!("Press Ctrl+C to exit."); + println!(); + + let _ = Command::new(&cli) + .args([ + "monitor", + "-p", &port, + "-c", &format!("baudrate={}", monitor_baud), + ]) + .status(); + } else { + println!(); + println!("To open serial monitor:"); + println!( + " anvil monitor -p {} -b {}", + port, monitor_baud + ); + } + + Ok(()) +} + +/// Upload cached build artifacts without recompiling. +pub fn run_upload_only( + sketch: &str, + port: Option<&str>, + verbose: bool, + fqbn_override: Option<&str>, +) -> Result<()> { + let sketch_path = resolve_sketch(sketch)?; + let sketch_name = sketch_name(&sketch_path)?; + let project_root = ProjectConfig::find_project_root(&sketch_path) + .ok(); + + let config = match &project_root { + Some(root) => ProjectConfig::load(root)?, + None => ProjectConfig::default(), + }; + + let fqbn = fqbn_override.unwrap_or(&config.build.fqbn); + + // Verify cached build exists + let cache_dir = build_cache_dir()?.join(&sketch_name); + if !cache_dir.exists() { + bail!( + "No cached build found for '{}'.\n\ + Run a compile first: anvil build --verify {}", + sketch_name, + sketch + ); + } + + let hex_name = format!("{}.ino.hex", sketch_name); + if !cache_dir.join(&hex_name).exists() { + bail!( + "Build cache exists but no .hex file found.\n\ + Try a clean rebuild: anvil build --clean {}", + sketch + ); + } + + println!(" {} Using cached build.", "ok".green()); + report_binary_size(&cache_dir, &sketch_name); + + let cli = board::find_arduino_cli() + .context("arduino-cli not found. Run: anvil setup")?; + + let port = match port { + Some(p) => p.to_string(), + None => board::auto_detect_port()?, + }; + + upload_to_board(&cli, fqbn, &port, &cache_dir, verbose)?; + + Ok(()) +} + +/// Upload compiled artifacts to the board. +fn upload_to_board( + cli: &Path, + fqbn: &str, + port: &str, + input_dir: &Path, + verbose: bool, +) -> Result<()> { + println!( + "Uploading to {}...", + port.bright_white().bold() + ); + + let mut upload_args = vec![ + "upload".to_string(), + "--fqbn".to_string(), + fqbn.to_string(), + "--port".to_string(), + port.to_string(), + "--input-dir".to_string(), + input_dir.display().to_string(), + ]; + + if verbose { + upload_args.push("--verbose".to_string()); + } + + let status = Command::new(cli) + .args(&upload_args) + .status() + .context("Failed to execute arduino-cli upload")?; + + if !status.success() { + bail!( + "Upload failed. Run with --verbose for details.\n\ + Also try: anvil devices" + ); + } + + println!(" {} Upload complete!", "ok".green()); + Ok(()) +} + +/// Resolve sketch argument to an absolute path. +fn resolve_sketch(sketch: &str) -> Result { + let path = PathBuf::from(sketch); + let abs = if path.is_absolute() { + path + } else { + std::env::current_dir()?.join(&path) + }; + + // Canonicalize if it exists + let resolved = if abs.exists() { + abs.canonicalize().unwrap_or(abs) + } else { + abs + }; + + if !resolved.is_dir() { + bail!("Not a directory: {}", resolved.display()); + } + + Ok(resolved) +} + +/// Extract the sketch name from a path (basename of the directory). +fn sketch_name(sketch_path: &Path) -> Result { + let name = sketch_path + .file_name() + .context("Could not determine sketch name")? + .to_string_lossy() + .to_string(); + Ok(name) +} + +/// Report binary size using avr-size if available. +fn report_binary_size(cache_dir: &Path, sketch_name: &str) { + let elf_name = format!("{}.ino.elf", sketch_name); + let elf_path = cache_dir.join(&elf_name); + + if !elf_path.exists() { + return; + } + + if which::which("avr-size").is_err() { + return; + } + + println!(); + let _ = Command::new("avr-size") + .args(["--mcu=atmega328p", "-C"]) + .arg(&elf_path) + .status(); + println!(); +} diff --git a/src/commands/devices.rs b/src/commands/devices.rs new file mode 100644 index 0000000..53f121b --- /dev/null +++ b/src/commands/devices.rs @@ -0,0 +1,67 @@ +use anyhow::Result; +use colored::*; + +use crate::board; + +pub fn scan_devices() -> Result<()> { + println!( + "{}", + "=== Connected Serial Devices ===".bold() + ); + println!(); + + let ports = board::list_ports(); + board::print_port_details(&ports); + + // Also run arduino-cli board list for cross-reference + if let Some(cli_path) = board::find_arduino_cli() { + println!(); + println!( + "{}", + "=== arduino-cli Board Detection ===".bold() + ); + println!(); + + let output = std::process::Command::new(&cli_path) + .args(["board", "list"]) + .output(); + + match output { + Ok(out) if out.status.success() => { + let stdout = String::from_utf8_lossy(&out.stdout); + for line in stdout.lines() { + println!(" {}", line); + } + } + _ => { + eprintln!( + " {}", + "arduino-cli board list failed (is the core installed?)" + .yellow() + ); + } + } + } else { + println!(); + eprintln!( + " {}", + "arduino-cli not found -- run 'anvil setup' first".yellow() + ); + } + + println!(); + + if ports.is_empty() { + println!("{}", "Troubleshooting:".bright_yellow().bold()); + println!(" - Try a different USB cable (many are charge-only)"); + println!(" - Try a different USB port"); + #[cfg(target_os = "linux")] + { + println!(" - Check kernel log: dmesg | tail -20"); + println!(" - Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'"); + } + println!(); + } + + Ok(()) +} diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs new file mode 100644 index 0000000..f25aa12 --- /dev/null +++ b/src/commands/doctor.rs @@ -0,0 +1,237 @@ +use anyhow::Result; +use colored::*; + +use crate::board; + +#[derive(Debug)] +pub struct SystemHealth { + pub arduino_cli_ok: bool, + pub arduino_cli_path: Option, + pub avr_core_ok: bool, + pub avr_size_ok: bool, + pub dialout_ok: bool, + pub cmake_ok: bool, + pub cpp_compiler_ok: bool, + pub git_ok: bool, + pub ports_found: usize, +} + +impl SystemHealth { + pub fn is_healthy(&self) -> bool { + self.arduino_cli_ok && self.avr_core_ok + } +} + +pub fn run_diagnostics() -> Result<()> { + println!( + "{}", + "Checking system health...".bright_yellow().bold() + ); + println!(); + + let health = check_system_health(); + print_diagnostics(&health); + + println!(); + if health.is_healthy() { + println!( + "{}", + "System is ready for Arduino development." + .bright_green() + .bold() + ); + } else { + println!( + "{}", + "Issues found. Run 'anvil setup' to fix." + .bright_yellow() + .bold() + ); + } + println!(); + + Ok(()) +} + +pub fn check_system_health() -> SystemHealth { + // arduino-cli + let (arduino_cli_ok, arduino_cli_path) = match board::find_arduino_cli() { + Some(path) => (true, Some(path.display().to_string())), + None => (false, None), + }; + + // AVR core + let avr_core_ok = if let Some(ref path_str) = arduino_cli_path { + let path = std::path::Path::new(path_str); + board::is_avr_core_installed(path) + } else { + false + }; + + // avr-size (optional) + let avr_size_ok = which::which("avr-size").is_ok(); + + // dialout group (Linux only) + let dialout_ok = check_dialout(); + + // cmake (optional -- for host tests) + let cmake_ok = which::which("cmake").is_ok(); + + // C++ compiler (optional -- for host tests) + let cpp_compiler_ok = which::which("g++").is_ok() || which::which("clang++").is_ok(); + + // git + let git_ok = which::which("git").is_ok(); + + // Serial ports + let ports_found = board::list_ports().len(); + + SystemHealth { + arduino_cli_ok, + arduino_cli_path, + avr_core_ok, + avr_size_ok, + dialout_ok, + cmake_ok, + cpp_compiler_ok, + git_ok, + ports_found, + } +} + +fn print_diagnostics(health: &SystemHealth) { + println!("{}", "Required:".bright_yellow().bold()); + println!(); + + // arduino-cli + if health.arduino_cli_ok { + println!( + " {} arduino-cli {}", + "ok".green(), + health + .arduino_cli_path + .as_ref() + .unwrap_or(&String::new()) + .bright_black() + ); + } else { + println!(" {} arduino-cli {}", "MISSING".red(), "not found in PATH".red()); + } + + // AVR core + if health.avr_core_ok { + println!(" {} arduino:avr core installed", "ok".green()); + } else if health.arduino_cli_ok { + println!( + " {} arduino:avr core {}", + "MISSING".red(), + "run: anvil setup".red() + ); + } else { + println!( + " {} arduino:avr core {}", + "MISSING".red(), + "(needs arduino-cli first)".bright_black() + ); + } + + println!(); + println!("{}", "Optional:".bright_yellow().bold()); + println!(); + + // avr-size + if health.avr_size_ok { + println!(" {} avr-size (binary size reporting)", "ok".green()); + } else { + println!( + " {} avr-size {}", + "--".bright_black(), + "install: sudo apt install gcc-avr".bright_black() + ); + } + + // dialout + #[cfg(unix)] + { + if health.dialout_ok { + println!(" {} user in dialout group", "ok".green()); + } else { + println!( + " {} dialout group {}", + "WARN".yellow(), + "run: sudo usermod -aG dialout $USER".yellow() + ); + } + } + + // cmake + if health.cmake_ok { + println!(" {} cmake (for host-side tests)", "ok".green()); + } else { + println!( + " {} cmake {}", + "--".bright_black(), + "install: sudo apt install cmake".bright_black() + ); + } + + // C++ compiler + if health.cpp_compiler_ok { + println!(" {} C++ compiler (g++/clang++)", "ok".green()); + } else { + println!( + " {} C++ compiler {}", + "--".bright_black(), + "install: sudo apt install g++".bright_black() + ); + } + + // git + if health.git_ok { + println!(" {} git", "ok".green()); + } else { + println!( + " {} git {}", + "--".bright_black(), + "install: sudo apt install git".bright_black() + ); + } + + println!(); + println!("{}", "Hardware:".bright_yellow().bold()); + println!(); + + if health.ports_found > 0 { + println!( + " {} {} serial port(s) detected", + "ok".green(), + health.ports_found + ); + } else { + println!( + " {} no serial ports {}", + "--".bright_black(), + "(plug in a board to detect)".bright_black() + ); + } +} + +fn check_dialout() -> bool { + #[cfg(unix)] + { + let output = std::process::Command::new("groups") + .output(); + match output { + Ok(out) => { + let groups = String::from_utf8_lossy(&out.stdout); + groups.contains("dialout") + } + Err(_) => false, + } + } + + #[cfg(not(unix))] + { + true // Not applicable on Windows + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..d016a5a --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,6 @@ +pub mod new; +pub mod doctor; +pub mod setup; +pub mod devices; +pub mod build; +pub mod monitor; diff --git a/src/commands/monitor.rs b/src/commands/monitor.rs new file mode 100644 index 0000000..e1d420a --- /dev/null +++ b/src/commands/monitor.rs @@ -0,0 +1,167 @@ +use anyhow::{Result, Context}; +use colored::*; +use std::process::Command; +use std::time::Duration; +use std::thread; + +use crate::board; + +const DEFAULT_BAUD: u32 = 115200; + +pub fn run_monitor( + port: Option<&str>, + baud: Option, + watch: bool, +) -> Result<()> { + let cli = board::find_arduino_cli() + .context("arduino-cli not found. Run: anvil setup")?; + + let baud = baud.unwrap_or(DEFAULT_BAUD); + + if watch { + run_watch(&cli, port, baud) + } else { + run_single(&cli, port, baud) + } +} + +/// Open serial monitor once. +fn run_single( + cli: &std::path::Path, + port: Option<&str>, + baud: u32, +) -> Result<()> { + let port = match port { + Some(p) => p.to_string(), + None => board::auto_detect_port()?, + }; + + println!( + "Opening serial monitor on {} at {} baud...", + port.bright_white().bold(), + baud + ); + println!("Press Ctrl+C to exit."); + println!(); + + let status = Command::new(cli) + .args([ + "monitor", + "-p", &port, + "-c", &format!("baudrate={}", baud), + ]) + .status() + .context("Failed to start serial monitor")?; + + if !status.success() { + anyhow::bail!("Serial monitor exited with error."); + } + + Ok(()) +} + +/// Persistent watch mode: reconnect after upload/reset/replug. +fn run_watch( + cli: &std::path::Path, + port_hint: Option<&str>, + baud: u32, +) -> Result<()> { + let port = match port_hint { + Some(p) => p.to_string(), + None => { + match board::auto_detect_port() { + Ok(p) => p, + Err(_) => { + let default = default_port(); + println!( + "No port detected yet. Waiting for {}...", + default + ); + default + } + } + } + }; + + println!( + "Persistent monitor on {} at {} baud", + port.bright_white().bold(), + baud + ); + println!("Reconnects automatically after upload / reset / replug."); + println!("Press Ctrl+C to exit."); + println!(); + + let running = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let r = running.clone(); + let _ = ctrlc::set_handler(move || { + r.store(false, std::sync::atomic::Ordering::Relaxed); + }); + + while running.load(std::sync::atomic::Ordering::Relaxed) { + if !port_exists(&port) { + println!( + "{}", + format!("--- Waiting for {} ...", port).bright_black() + ); + while !port_exists(&port) + && running.load(std::sync::atomic::Ordering::Relaxed) + { + thread::sleep(Duration::from_millis(500)); + } + + if !running.load(std::sync::atomic::Ordering::Relaxed) { + break; + } + + // Settle time + thread::sleep(Duration::from_secs(1)); + println!("{}", format!("--- {} connected ---", port).green()); + } + + let _ = Command::new(cli.as_os_str()) + .args([ + "monitor", + "-p", &port, + "-c", &format!("baudrate={}", baud), + ]) + .status(); + + if !running.load(std::sync::atomic::Ordering::Relaxed) { + break; + } + + println!( + "{}", + format!("--- {} disconnected ---", port).yellow() + ); + thread::sleep(Duration::from_millis(500)); + } + + println!(); + println!("Monitor stopped."); + Ok(()) +} + +fn port_exists(port: &str) -> bool { + #[cfg(unix)] + { + std::path::Path::new(port).exists() + } + + #[cfg(windows)] + { + // On Windows, check if the port appears in current device list + board::list_ports() + .iter() + .any(|p| p.port_name == port) + } +} + +fn default_port() -> String { + if cfg!(target_os = "windows") { + "COM3".to_string() + } else { + "/dev/ttyUSB0".to_string() + } +} diff --git a/src/commands/new.rs b/src/commands/new.rs new file mode 100644 index 0000000..338a938 --- /dev/null +++ b/src/commands/new.rs @@ -0,0 +1,252 @@ +use anyhow::{Result, bail}; +use colored::*; +use std::path::PathBuf; + +use crate::templates::{TemplateManager, TemplateContext}; +use crate::version::ANVIL_VERSION; + +pub fn list_templates() -> Result<()> { + println!("{}", "Available templates:".bright_cyan().bold()); + println!(); + + for info in TemplateManager::list_templates() { + let marker = if info.is_default { " (default)" } else { "" }; + println!( + " {}{}", + info.name.bright_white().bold(), + marker.bright_cyan() + ); + println!(" {}", info.description); + println!(); + } + + println!("{}", "Usage:".bright_yellow().bold()); + println!(" anvil new "); + println!(" anvil new --template basic"); + println!(); + + Ok(()) +} + +pub fn create_project(name: &str, template: Option<&str>) -> Result<()> { + // Validate project name + validate_project_name(name)?; + + let project_path = PathBuf::from(name); + if project_path.exists() { + bail!( + "Directory already exists: {}\n\ + Choose a different name or remove the existing directory.", + project_path.display() + ); + } + + let template_name = template.unwrap_or("basic"); + if !TemplateManager::template_exists(template_name) { + println!( + "{}", + format!("Template '{}' not found.", template_name).red().bold() + ); + println!(); + list_templates()?; + bail!("Invalid template"); + } + + println!( + "{}", + format!("Creating Arduino project: {}", name) + .bright_green() + .bold() + ); + println!("{}", format!("Template: {}", template_name).bright_cyan()); + println!(); + + // Create project directory + std::fs::create_dir_all(&project_path)?; + + // Extract template + println!("{}", "Extracting template files...".bright_yellow()); + let context = TemplateContext { + project_name: name.to_string(), + anvil_version: ANVIL_VERSION.to_string(), + }; + + let file_count = TemplateManager::extract(template_name, &project_path, &context)?; + println!("{} Extracted {} files", "ok".green(), file_count); + + // Make shell scripts executable on Unix + #[cfg(unix)] + { + make_executable(&project_path); + } + + // Initialize git repository + init_git(&project_path, template_name); + + // Print success + println!(); + println!( + "{}", + "================================================================" + .bright_green() + ); + println!( + "{}", + format!(" Project created: {}", name) + .bright_green() + .bold() + ); + println!( + "{}", + "================================================================" + .bright_green() + ); + println!(); + + print_next_steps(name); + + Ok(()) +} + +fn validate_project_name(name: &str) -> Result<()> { + if name.is_empty() { + bail!("Project name cannot be empty"); + } + if name.len() > 50 { + bail!("Project name must be 50 characters or less"); + } + if !name.chars().next().unwrap().is_alphabetic() { + bail!("Project name must start with a letter"); + } + let valid = name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_'); + if !valid { + bail!( + "Invalid project name '{}'\n\ + Names may contain: letters, numbers, hyphens, underscores\n\ + Must start with a letter.\n\n\ + Valid examples:\n\ + \x20 blink\n\ + \x20 my-sensor\n\ + \x20 team1234_robot", + name + ); + } + Ok(()) +} + +fn init_git(project_dir: &PathBuf, template_name: &str) { + println!("{}", "Initializing git repository...".bright_yellow()); + + // Check if git is available + if which::which("git").is_err() { + eprintln!( + "{} git not found, skipping repository initialization.", + "warn".yellow() + ); + return; + } + + let run = |args: &[&str]| -> bool { + std::process::Command::new("git") + .args(args) + .current_dir(project_dir) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + }; + + if !run(&["init"]) { + eprintln!("{} Failed to initialize git.", "warn".yellow()); + return; + } + + run(&["add", "."]); + + let msg = format!( + "Initial commit from anvil new --template {}", + template_name + ); + run(&["commit", "-m", &msg]); + + println!("{} Git repository initialized", "ok".green()); +} + +#[cfg(unix)] +fn make_executable(project_dir: &PathBuf) { + use std::os::unix::fs::PermissionsExt; + + let scripts = ["test/run_tests.sh"]; + for script in &scripts { + let path = project_dir.join(script); + if path.exists() { + if let Ok(meta) = std::fs::metadata(&path) { + let mut perms = meta.permissions(); + perms.set_mode(0o755); + let _ = std::fs::set_permissions(&path, perms); + } + } + } +} + +fn print_next_steps(project_name: &str) { + println!("{}", "Next steps:".bright_yellow().bold()); + println!( + " 1. {}", + format!("cd {}", project_name).bright_cyan() + ); + println!(" 2. Check your system: {}", "anvil doctor".bright_cyan()); + println!( + " 3. Find your board: {}", + "anvil devices".bright_cyan() + ); + println!( + " 4. Build and upload: {}", + format!("anvil build {}", project_name).bright_cyan() + ); + println!( + " 5. Build + monitor: {}", + format!("anvil build --monitor {}", project_name).bright_cyan() + ); + println!(); + println!( + " Run host tests: {}", + "cd test && ./run_tests.sh".bright_cyan() + ); + println!(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_name_valid() { + assert!(validate_project_name("blink").is_ok()); + assert!(validate_project_name("my-sensor").is_ok()); + assert!(validate_project_name("team1234_robot").is_ok()); + } + + #[test] + fn test_validate_name_empty() { + assert!(validate_project_name("").is_err()); + } + + #[test] + fn test_validate_name_starts_with_number() { + assert!(validate_project_name("123abc").is_err()); + } + + #[test] + fn test_validate_name_special_chars() { + assert!(validate_project_name("my project").is_err()); + assert!(validate_project_name("my.project").is_err()); + } + + #[test] + fn test_validate_name_too_long() { + let long_name = "a".repeat(51); + assert!(validate_project_name(&long_name).is_err()); + } +} diff --git a/src/commands/setup.rs b/src/commands/setup.rs new file mode 100644 index 0000000..26cfd3b --- /dev/null +++ b/src/commands/setup.rs @@ -0,0 +1,163 @@ +use anyhow::Result; +use colored::*; +use std::process::Command; + +use crate::board; + +pub fn run_setup() -> Result<()> { + println!( + "{}", + "First-time setup".bright_yellow().bold() + ); + println!(); + + // 1. Check for arduino-cli + println!("{}", "Checking for arduino-cli...".bright_yellow()); + let cli_path = match board::find_arduino_cli() { + Some(path) => { + println!(" {} Found: {}", "ok".green(), path.display()); + path + } + None => { + println!(" {} arduino-cli not found.", "MISSING".red()); + println!(); + print_install_instructions(); + anyhow::bail!( + "Install arduino-cli first, then re-run: anvil setup" + ); + } + }; + println!(); + + // 2. Update board index + println!("{}", "Updating board index...".bright_yellow()); + let status = Command::new(&cli_path) + .args(["core", "update-index"]) + .status(); + + match status { + Ok(s) if s.success() => { + println!(" {} Board index updated.", "ok".green()); + } + _ => { + eprintln!( + " {} Failed to update board index.", + "warn".yellow() + ); + } + } + println!(); + + // 3. Install arduino:avr core + println!("{}", "Checking arduino:avr core...".bright_yellow()); + if board::is_avr_core_installed(&cli_path) { + println!(" {} arduino:avr core already installed.", "ok".green()); + } else { + println!(" Installing arduino:avr core (this may take a minute)..."); + let status = Command::new(&cli_path) + .args(["core", "install", "arduino:avr"]) + .status(); + + match status { + Ok(s) if s.success() => { + println!(" {} arduino:avr core installed.", "ok".green()); + } + _ => { + eprintln!( + " {} Failed to install arduino:avr core.", + "FAIL".red() + ); + } + } + } + println!(); + + // 4. Check optional tools + println!("{}", "Checking optional tools...".bright_yellow()); + + if which::which("avr-size").is_ok() { + println!(" {} avr-size (binary size reporting)", "ok".green()); + } else { + println!( + " {} avr-size not found. Install for binary size details:", + "info".bright_black() + ); + println!(" sudo apt install gcc-avr"); + } + + #[cfg(unix)] + { + let in_dialout = std::process::Command::new("groups") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).contains("dialout")) + .unwrap_or(false); + + if in_dialout { + println!(" {} User in dialout group", "ok".green()); + } else { + println!( + " {} Not in dialout group. Fix with:", + "warn".yellow() + ); + println!(" sudo usermod -aG dialout $USER"); + println!(" Then log out and back in."); + } + } + println!(); + + // 5. Scan for devices + println!("{}", "Scanning for boards...".bright_yellow()); + let ports = board::list_ports(); + board::print_port_details(&ports); + println!(); + + // Summary + println!( + "{}", + "================================================================" + .bright_green() + ); + println!("{}", " Setup complete!".bright_green().bold()); + println!( + "{}", + "================================================================" + .bright_green() + ); + println!(); + println!("{}", "Next steps:".bright_yellow().bold()); + println!(" 1. Plug in your RedBoard"); + println!(" 2. {}", "anvil devices".bright_cyan()); + println!(" 3. {}", "anvil new blink".bright_cyan()); + println!( + " 4. {}", + "cd blink && anvil build blink".bright_cyan() + ); + println!(); + + Ok(()) +} + +fn print_install_instructions() { + println!("{}", "Install arduino-cli:".bright_yellow().bold()); + println!(); + + if cfg!(target_os = "linux") { + println!(" curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh"); + println!(" sudo mv bin/arduino-cli /usr/local/bin/"); + println!(); + println!(" Or via package manager:"); + println!(" sudo apt install arduino-cli (Debian/Ubuntu)"); + println!(" yay -S arduino-cli (Arch)"); + } else if cfg!(target_os = "macos") { + println!(" brew install arduino-cli"); + } else if cfg!(target_os = "windows") { + println!(" Download from: https://arduino.github.io/arduino-cli/installation/"); + println!(" Or via Chocolatey:"); + println!(" choco install arduino-cli"); + println!(" Or via WinGet:"); + println!(" winget install ArduinoSA.CLI"); + } + + println!(); + println!(" Then re-run: anvil setup"); +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..fa6409f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,5 @@ +pub mod version; +pub mod commands; +pub mod project; +pub mod board; +pub mod templates; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..48d52fe --- /dev/null +++ b/src/main.rs @@ -0,0 +1,196 @@ +use clap::{Parser, Subcommand}; +use colored::*; +use anyhow::Result; +use anvil::version::ANVIL_VERSION; + +mod commands { + pub use anvil::commands::*; +} + +#[derive(Parser)] +#[command(name = "anvil")] +#[command(author = "Eric Ratliff ")] +#[command(version = ANVIL_VERSION)] +#[command( + about = "Arduino project generator and build tool - forges clean embedded projects", + long_about = None +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Create a new Arduino project + New { + /// Project name + name: Option, + + /// Template to use (basic) + #[arg(long, short = 't', value_name = "TEMPLATE")] + template: Option, + + /// List available templates + #[arg(long, conflicts_with = "name")] + list_templates: bool, + }, + + /// Check system health and diagnose issues + Doctor, + + /// Install arduino-cli and required cores + Setup, + + /// List connected boards and serial ports + Devices, + + /// Compile a sketch (and optionally upload) + Build { + /// Path to sketch directory + sketch: String, + + /// Compile only -- do not upload + #[arg(long)] + verify: bool, + + /// Open serial monitor after upload + #[arg(long)] + monitor: bool, + + /// Delete cached build artifacts first + #[arg(long)] + clean: bool, + + /// Show full compiler output + #[arg(long)] + verbose: bool, + + /// Serial port (auto-detected if omitted) + #[arg(short, long)] + port: Option, + + /// Serial monitor baud rate + #[arg(short, long)] + baud: Option, + + /// Override Fully Qualified Board Name + #[arg(long)] + fqbn: Option, + }, + + /// Upload cached build artifacts (no recompile) + Upload { + /// Path to sketch directory + sketch: String, + + /// Serial port (auto-detected if omitted) + #[arg(short, long)] + port: Option, + + /// Show full avrdude output + #[arg(long)] + verbose: bool, + + /// Override Fully Qualified Board Name + #[arg(long)] + fqbn: Option, + }, + + /// Open serial monitor + Monitor { + /// Serial port (auto-detected if omitted) + #[arg(short, long)] + port: Option, + + /// Baud rate (default: from project config or 115200) + #[arg(short, long)] + baud: Option, + + /// Persistent mode: reconnect after upload/reset/replug + #[arg(long)] + watch: bool, + }, +} + +fn main() -> Result<()> { + #[cfg(windows)] + colored::control::set_virtual_terminal(true).ok(); + + let cli = Cli::parse(); + + print_banner(); + + match cli.command { + Commands::New { name, template, list_templates } => { + if list_templates { + commands::new::list_templates() + } else if let Some(project_name) = name { + commands::new::create_project( + &project_name, + template.as_deref(), + ) + } else { + anyhow::bail!( + "Project name required.\n\ + Usage: anvil new \n\ + List templates: anvil new --list-templates" + ); + } + } + Commands::Doctor => { + commands::doctor::run_diagnostics() + } + Commands::Setup => { + commands::setup::run_setup() + } + Commands::Devices => { + commands::devices::scan_devices() + } + Commands::Build { + sketch, verify, monitor, clean, verbose, + port, baud, fqbn, + } => { + commands::build::run_build( + &sketch, verify, monitor, clean, verbose, + port.as_deref(), baud, fqbn.as_deref(), + ) + } + Commands::Upload { sketch, port, verbose, fqbn } => { + commands::build::run_upload_only( + &sketch, + port.as_deref(), + verbose, + fqbn.as_deref(), + ) + } + Commands::Monitor { port, baud, watch } => { + commands::monitor::run_monitor( + port.as_deref(), + baud, + watch, + ) + } + } +} + +fn print_banner() { + println!( + "{}", + "================================================================" + .bright_cyan() + ); + println!( + "{}", + format!(" Anvil - Arduino Build Tool v{}", ANVIL_VERSION) + .bright_cyan() + .bold() + ); + println!("{}", " Nexus Workshops LLC".bright_cyan()); + println!( + "{}", + "================================================================" + .bright_cyan() + ); + println!(); +} diff --git a/src/project/config.rs b/src/project/config.rs new file mode 100644 index 0000000..f1981eb --- /dev/null +++ b/src/project/config.rs @@ -0,0 +1,226 @@ +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::fs; +use anyhow::{Result, Context, bail}; + +use crate::version::ANVIL_VERSION; + +pub const CONFIG_FILENAME: &str = ".anvil.toml"; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ProjectConfig { + pub project: ProjectMeta, + pub build: BuildConfig, + pub monitor: MonitorConfig, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ProjectMeta { + pub name: String, + pub anvil_version: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BuildConfig { + pub fqbn: String, + pub warnings: String, + pub include_dirs: Vec, + pub extra_flags: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct MonitorConfig { + pub baud: u32, +} + +impl ProjectConfig { + /// Create a new project config with sensible defaults. + pub fn new(name: &str) -> Self { + Self { + project: ProjectMeta { + name: name.to_string(), + anvil_version: ANVIL_VERSION.to_string(), + }, + build: BuildConfig { + fqbn: "arduino:avr:uno".to_string(), + warnings: "more".to_string(), + include_dirs: vec!["lib/hal".to_string(), "lib/app".to_string()], + extra_flags: vec!["-Werror".to_string()], + }, + monitor: MonitorConfig { + baud: 115200, + }, + } + } + + /// Load config from a project directory. + pub fn load(project_root: &Path) -> Result { + let config_path = project_root.join(CONFIG_FILENAME); + if !config_path.exists() { + bail!( + "Not an Anvil project (missing {}).\n\ + Create one with: anvil new ", + CONFIG_FILENAME + ); + } + let contents = fs::read_to_string(&config_path) + .context(format!("Failed to read {}", config_path.display()))?; + let config: ProjectConfig = toml::from_str(&contents) + .context(format!("Failed to parse {}", config_path.display()))?; + Ok(config) + } + + /// Save config to a project directory. + pub fn save(&self, project_root: &Path) -> Result<()> { + let config_path = project_root.join(CONFIG_FILENAME); + let contents = toml::to_string_pretty(self) + .context("Failed to serialize config")?; + fs::write(&config_path, contents) + .context(format!("Failed to write {}", config_path.display()))?; + Ok(()) + } + + /// Walk up from a directory to find the project root containing .anvil.toml. + pub fn find_project_root(start: &Path) -> Result { + let mut dir = if start.is_absolute() { + start.to_path_buf() + } else { + std::env::current_dir()?.join(start) + }; + + for _ in 0..10 { + if dir.join(CONFIG_FILENAME).exists() { + return Ok(dir); + } + match dir.parent() { + Some(parent) => dir = parent.to_path_buf(), + None => break, + } + } + + bail!( + "No {} found in {} or any parent directory.\n\ + Create a project with: anvil new ", + CONFIG_FILENAME, + start.display() + ); + } + + /// Resolve include directories to absolute paths relative to project root. + pub fn resolve_include_flags(&self, project_root: &Path) -> Vec { + let mut flags = Vec::new(); + for dir in &self.build.include_dirs { + let abs = project_root.join(dir); + if abs.is_dir() { + flags.push(format!("-I{}", abs.display())); + } + } + flags + } + + /// Build the full extra_flags string for arduino-cli. + pub fn extra_flags_string(&self, project_root: &Path) -> String { + let mut parts = self.resolve_include_flags(project_root); + for flag in &self.build.extra_flags { + parts.push(flag.clone()); + } + parts.join(" ") + } +} + +impl Default for ProjectConfig { + fn default() -> Self { + Self::new("untitled") + } +} + +/// Return the Anvil home directory (~/.anvil). +pub fn anvil_home() -> Result { + let home = dirs::home_dir() + .context("Could not determine home directory")?; + let anvil_dir = home.join(".anvil"); + fs::create_dir_all(&anvil_dir)?; + Ok(anvil_dir) +} + +/// Return the build cache directory (~/.anvil/builds). +pub fn build_cache_dir() -> Result { + let dir = anvil_home()?.join("builds"); + fs::create_dir_all(&dir)?; + Ok(dir) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_new_config_defaults() { + let config = ProjectConfig::new("test_project"); + assert_eq!(config.project.name, "test_project"); + assert_eq!(config.build.fqbn, "arduino:avr:uno"); + assert_eq!(config.monitor.baud, 115200); + assert!(config.build.include_dirs.contains(&"lib/hal".to_string())); + } + + #[test] + fn test_save_and_load() { + let tmp = TempDir::new().unwrap(); + let config = ProjectConfig::new("roundtrip"); + config.save(tmp.path()).unwrap(); + + let loaded = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(loaded.project.name, "roundtrip"); + assert_eq!(loaded.build.fqbn, config.build.fqbn); + assert_eq!(loaded.monitor.baud, config.monitor.baud); + } + + #[test] + fn test_find_project_root() { + let tmp = TempDir::new().unwrap(); + let config = ProjectConfig::new("finder"); + config.save(tmp.path()).unwrap(); + + // Create a subdirectory and search from there + let sub = tmp.path().join("sketch").join("deep"); + fs::create_dir_all(&sub).unwrap(); + + let found = ProjectConfig::find_project_root(&sub).unwrap(); + assert_eq!(found, tmp.path()); + } + + #[test] + fn test_find_project_root_not_found() { + let tmp = TempDir::new().unwrap(); + let result = ProjectConfig::find_project_root(tmp.path()); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_include_flags() { + let tmp = TempDir::new().unwrap(); + fs::create_dir_all(tmp.path().join("lib/hal")).unwrap(); + fs::create_dir_all(tmp.path().join("lib/app")).unwrap(); + + let config = ProjectConfig::new("includes"); + let flags = config.resolve_include_flags(tmp.path()); + + assert_eq!(flags.len(), 2); + assert!(flags[0].starts_with("-I")); + assert!(flags[0].contains("lib")); + } + + #[test] + fn test_extra_flags_string() { + let tmp = TempDir::new().unwrap(); + fs::create_dir_all(tmp.path().join("lib/hal")).unwrap(); + fs::create_dir_all(tmp.path().join("lib/app")).unwrap(); + + let config = ProjectConfig::new("flags"); + let flags = config.extra_flags_string(tmp.path()); + + assert!(flags.contains("-Werror")); + assert!(flags.contains("-I")); + } +} diff --git a/src/project/mod.rs b/src/project/mod.rs new file mode 100644 index 0000000..9c74d4b --- /dev/null +++ b/src/project/mod.rs @@ -0,0 +1,3 @@ +pub mod config; + +pub use config::ProjectConfig; diff --git a/src/templates/mod.rs b/src/templates/mod.rs new file mode 100644 index 0000000..3eef411 --- /dev/null +++ b/src/templates/mod.rs @@ -0,0 +1,234 @@ +use include_dir::{include_dir, Dir}; +use std::path::Path; +use std::fs; +use anyhow::{Result, bail, Context}; + +use crate::version::ANVIL_VERSION; + +static BASIC_TEMPLATE: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/basic"); + +pub struct TemplateContext { + pub project_name: String, + pub anvil_version: String, +} + +pub struct TemplateManager; + +impl TemplateManager { + pub fn template_exists(name: &str) -> bool { + matches!(name, "basic") + } + + pub fn list_templates() -> Vec { + vec![ + TemplateInfo { + name: "basic".to_string(), + description: "Arduino project with HAL abstraction, mocks, and test infrastructure".to_string(), + is_default: true, + }, + ] + } + + /// Extract a template into the output directory, applying variable + /// substitution and filename transformations. + pub fn extract( + template_name: &str, + output_dir: &Path, + context: &TemplateContext, + ) -> Result { + let template_dir = match template_name { + "basic" => &BASIC_TEMPLATE, + _ => bail!("Unknown template: {}", template_name), + }; + + let count = extract_dir(template_dir, output_dir, "", context)?; + Ok(count) + } +} + +pub struct TemplateInfo { + pub name: String, + pub description: String, + pub is_default: bool, +} + +/// Recursively extract a directory from the embedded template. +fn extract_dir( + source: &Dir<'_>, + output_base: &Path, + relative_prefix: &str, + context: &TemplateContext, +) -> Result { + let mut count = 0; + + for file in source.files() { + let file_path = file.path(); + let file_name = file_path.to_string_lossy().to_string(); + + // Build the output path with transformations + let output_rel = transform_path(&file_name, &context.project_name); + let output_path = output_base.join(&output_rel); + + // Create parent directories + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent) + .context(format!("Failed to create directory: {}", parent.display()))?; + } + + // Read file contents + let contents = file.contents(); + + // Check if this is a template file (.tmpl suffix) + if output_rel.ends_with(".tmpl") { + // Variable substitution + let text = std::str::from_utf8(contents) + .context("Template file must be UTF-8")?; + let processed = substitute_variables(text, context); + + // Remove .tmpl extension + let final_path_str = output_rel.trim_end_matches(".tmpl"); + let final_path = output_base.join(final_path_str); + + if let Some(parent) = final_path.parent() { + fs::create_dir_all(parent)?; + } + + fs::write(&final_path, processed)?; + count += 1; + } else { + fs::write(&output_path, contents)?; + count += 1; + } + } + + // Recurse into subdirectories + for dir in source.dirs() { + count += extract_dir(dir, output_base, relative_prefix, context)?; + } + + Ok(count) +} + +/// Transform template file paths: +/// - `_dot_` prefix -> `.` prefix (hidden files) +/// - `__name__` -> project name +fn transform_path(path: &str, project_name: &str) -> String { + let mut result = path.to_string(); + + // Replace __name__ with project name in all path components + result = result.replace("__name__", project_name); + + // Handle _dot_ prefix for hidden files. + // Split into components and transform each. + let parts: Vec<&str> = result.split('/').collect(); + let transformed: Vec = parts + .iter() + .map(|part| { + if let Some(rest) = part.strip_prefix("_dot_") { + format!(".{}", rest) + } else { + part.to_string() + } + }) + .collect(); + + transformed.join(std::path::MAIN_SEPARATOR_STR) +} + +/// Simple variable substitution: replace {{VAR}} with values. +fn substitute_variables(text: &str, context: &TemplateContext) -> String { + text.replace("{{PROJECT_NAME}}", &context.project_name) + .replace("{{ANVIL_VERSION}}", &context.anvil_version) + .replace("{{ANVIL_VERSION_CURRENT}}", ANVIL_VERSION) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_transform_path_dot_prefix() { + assert_eq!( + transform_path("_dot_gitignore", "blink"), + ".gitignore" + ); + assert_eq!( + transform_path("_dot_vscode/settings.json", "blink"), + format!(".vscode{}settings.json", std::path::MAIN_SEPARATOR) + ); + } + + #[test] + fn test_transform_path_name_substitution() { + assert_eq!( + transform_path("__name__/__name__.ino.tmpl", "blink"), + format!("blink{}blink.ino.tmpl", std::path::MAIN_SEPARATOR) + ); + } + + #[test] + fn test_substitute_variables() { + let ctx = TemplateContext { + project_name: "my_project".to_string(), + anvil_version: "1.0.0".to_string(), + }; + let input = "Name: {{PROJECT_NAME}}, Version: {{ANVIL_VERSION}}"; + let output = substitute_variables(input, &ctx); + assert_eq!(output, "Name: my_project, Version: 1.0.0"); + } + + #[test] + fn test_template_exists() { + assert!(TemplateManager::template_exists("basic")); + assert!(!TemplateManager::template_exists("nonexistent")); + } + + #[test] + fn test_extract_basic_template() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "test_proj".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + let count = TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + assert!(count > 0, "Should extract at least one file"); + + // Verify key files exist + assert!( + tmp.path().join(".anvil.toml").exists(), + ".anvil.toml should be created" + ); + assert!( + tmp.path().join("test_proj").join("test_proj.ino").exists(), + "Sketch .ino should be created" + ); + assert!( + tmp.path().join("lib").join("hal").join("hal.h").exists(), + "HAL header should be created" + ); + assert!( + tmp.path().join(".gitignore").exists(), + ".gitignore should be created" + ); + } + + #[test] + fn test_extract_template_variable_substitution() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "my_sensor".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + // Read the generated .anvil.toml and check for project name + let config_content = fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap(); + assert!( + config_content.contains("my_sensor"), + ".anvil.toml should contain project name" + ); + } +} diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 0000000..8a88ab9 --- /dev/null +++ b/src/version.rs @@ -0,0 +1 @@ +pub const ANVIL_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/templates/basic/README.md.tmpl b/templates/basic/README.md.tmpl new file mode 100644 index 0000000..4bae2aa --- /dev/null +++ b/templates/basic/README.md.tmpl @@ -0,0 +1,74 @@ +# {{PROJECT_NAME}} + +Arduino project generated by Anvil v{{ANVIL_VERSION}}. + +## Quick Start + +```bash +# Check your system +anvil doctor + +# Find connected boards +anvil devices + +# Compile only (no upload) +anvil build --verify {{PROJECT_NAME}} + +# Compile and upload +anvil build {{PROJECT_NAME}} + +# Compile, upload, and open serial monitor +anvil build --monitor {{PROJECT_NAME}} + +# Run host-side unit tests (no board needed) +cd test && ./run_tests.sh +``` + +## Project Structure + +``` +{{PROJECT_NAME}}/ + {{PROJECT_NAME}}/ + {{PROJECT_NAME}}.ino Entry point (setup + loop) + lib/ + hal/ + hal.h Hardware abstraction interface + hal_arduino.h Real hardware implementation + app/ + {{PROJECT_NAME}}_app.h Application logic (testable) + test/ + mocks/ + mock_hal.h Google Mock HAL + sim_hal.h Stateful simulator HAL + test_unit.cpp Unit tests + CMakeLists.txt Test build system + run_tests.sh Test runner (Linux/Mac) + run_tests.bat Test runner (Windows) + .anvil.toml Project configuration +``` + +## Architecture + +All hardware access goes through the `Hal` interface. The app code +(`lib/app/`) depends only on `Hal`, never on `Arduino.h` directly. +This means the app can be compiled and tested on the host without +any Arduino SDK. + +Two HAL implementations: +- `ArduinoHal` -- passthroughs to real hardware (used in the .ino) +- `MockHal` -- Google Mock for verifying exact call sequences in tests + +## Configuration + +Edit `.anvil.toml` to change board, baud rate, or build settings: + +```toml +[build] +fqbn = "arduino:avr:uno" +warnings = "more" +include_dirs = ["lib/hal", "lib/app"] +extra_flags = ["-Werror"] + +[monitor] +baud = 115200 +``` diff --git a/templates/basic/__name__/__name__.ino.tmpl b/templates/basic/__name__/__name__.ino.tmpl new file mode 100644 index 0000000..efe5f56 --- /dev/null +++ b/templates/basic/__name__/__name__.ino.tmpl @@ -0,0 +1,28 @@ +/* + * {{PROJECT_NAME}}.ino -- LED blink with button-controlled speed + * + * This .ino file is the entry point. All logic lives in the app + * header (lib/app/{{PROJECT_NAME}}_app.h) which depends on the HAL + * interface (lib/hal/hal.h), making it testable on the host. + * + * Wiring: + * Pin 13 (LED_BUILTIN) -- onboard LED (no wiring needed) + * Pin 2 -- momentary button to GND (uses INPUT_PULLUP) + * + * Serial: 115200 baud + * Prints "FAST" or "SLOW" on button press. + */ + +#include +#include <{{PROJECT_NAME}}_app.h> + +static ArduinoHal hw; +static BlinkApp app(&hw); + +void setup() { + app.begin(); +} + +void loop() { + app.update(); +} diff --git a/templates/basic/_dot_anvil.toml.tmpl b/templates/basic/_dot_anvil.toml.tmpl new file mode 100644 index 0000000..c8afc54 --- /dev/null +++ b/templates/basic/_dot_anvil.toml.tmpl @@ -0,0 +1,12 @@ +[project] +name = "{{PROJECT_NAME}}" +anvil_version = "{{ANVIL_VERSION}}" + +[build] +fqbn = "arduino:avr:uno" +warnings = "more" +include_dirs = ["lib/hal", "lib/app"] +extra_flags = ["-Werror"] + +[monitor] +baud = 115200 diff --git a/templates/basic/_dot_clang-format b/templates/basic/_dot_clang-format new file mode 100644 index 0000000..fb9098b --- /dev/null +++ b/templates/basic/_dot_clang-format @@ -0,0 +1,8 @@ +BasedOnStyle: Google +IndentWidth: 4 +ColumnLimit: 100 +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: Never +BreakBeforeBraces: Attach +PointerAlignment: Left +SortIncludes: false diff --git a/templates/basic/_dot_editorconfig b/templates/basic/_dot_editorconfig new file mode 100644 index 0000000..415293e --- /dev/null +++ b/templates/basic/_dot_editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.{h,cpp,ino}] +indent_size = 4 + +[CMakeLists.txt] +indent_size = 4 + +[*.{sh,bat}] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/templates/basic/_dot_gitignore b/templates/basic/_dot_gitignore new file mode 100644 index 0000000..36539fa --- /dev/null +++ b/templates/basic/_dot_gitignore @@ -0,0 +1,10 @@ +# Build artifacts +test/build/ + +# IDE +.vscode/.browse* +.vscode/*.log + +# OS +.DS_Store +Thumbs.db diff --git a/templates/basic/_dot_vscode/settings.json b/templates/basic/_dot_vscode/settings.json new file mode 100644 index 0000000..18768d5 --- /dev/null +++ b/templates/basic/_dot_vscode/settings.json @@ -0,0 +1,21 @@ +{ + "files.associations": { + "*.ino": "cpp", + "*.h": "cpp" + }, + "C_Cpp.default.includePath": [ + "${workspaceFolder}/lib/hal", + "${workspaceFolder}/lib/app" + ], + "C_Cpp.default.defines": [ + "LED_BUILTIN=13", + "INPUT=0x0", + "OUTPUT=0x1", + "INPUT_PULLUP=0x2", + "LOW=0x0", + "HIGH=0x1" + ], + "editor.formatOnSave": false, + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true +} diff --git a/templates/basic/lib/app/__name___app.h.tmpl b/templates/basic/lib/app/__name___app.h.tmpl new file mode 100644 index 0000000..27f384b --- /dev/null +++ b/templates/basic/lib/app/__name___app.h.tmpl @@ -0,0 +1,88 @@ +#ifndef APP_H +#define APP_H + +#include + +/* + * BlinkApp -- Testable blink logic, decoupled from hardware. + * + * Blinks an LED and reads a button. When the button is pressed, + * the blink rate doubles (toggles between normal and fast mode). + * + * All hardware access goes through the injected Hal pointer. This + * class has no dependency on Arduino.h and compiles on any host. + */ +class BlinkApp { +public: + static constexpr uint8_t DEFAULT_LED_PIN = LED_BUILTIN; // pin 13 + static constexpr uint8_t DEFAULT_BUTTON_PIN = 2; + static constexpr unsigned long SLOW_INTERVAL_MS = 500; + static constexpr unsigned long FAST_INTERVAL_MS = 125; + + BlinkApp(Hal* hal, + uint8_t led_pin = DEFAULT_LED_PIN, + uint8_t button_pin = DEFAULT_BUTTON_PIN) + : hal_(hal) + , led_pin_(led_pin) + , button_pin_(button_pin) + , led_state_(LOW) + , fast_mode_(false) + , last_toggle_ms_(0) + , last_button_state_(HIGH) // pulled up, so HIGH = not pressed + {} + + // Call once from setup() + void begin() { + hal_->pinMode(led_pin_, OUTPUT); + hal_->pinMode(button_pin_, INPUT_PULLUP); + hal_->serialBegin(115200); + hal_->serialPrintln("BlinkApp started"); + last_toggle_ms_ = hal_->millis(); + } + + // Call repeatedly from loop() + void update() { + handleButton(); + handleBlink(); + } + + // -- Accessors for testing ---------------------------------------------- + bool ledState() const { return led_state_ == HIGH; } + bool fastMode() const { return fast_mode_; } + unsigned long interval() const { + return fast_mode_ ? FAST_INTERVAL_MS : SLOW_INTERVAL_MS; + } + +private: + void handleButton() { + uint8_t reading = hal_->digitalRead(button_pin_); + + // Detect falling edge (HIGH -> LOW = button press with INPUT_PULLUP) + if (last_button_state_ == HIGH && reading == LOW) { + fast_mode_ = !fast_mode_; + hal_->serialPrintln(fast_mode_ ? "FAST" : "SLOW"); + } + last_button_state_ = reading; + } + + void handleBlink() { + unsigned long now = hal_->millis(); + unsigned long target = fast_mode_ ? FAST_INTERVAL_MS : SLOW_INTERVAL_MS; + + if (now - last_toggle_ms_ >= target) { + led_state_ = (led_state_ == HIGH) ? LOW : HIGH; + hal_->digitalWrite(led_pin_, led_state_); + last_toggle_ms_ = now; + } + } + + Hal* hal_; + uint8_t led_pin_; + uint8_t button_pin_; + uint8_t led_state_; + bool fast_mode_; + unsigned long last_toggle_ms_; + uint8_t last_button_state_; +}; + +#endif // APP_H diff --git a/templates/basic/lib/hal/hal.h b/templates/basic/lib/hal/hal.h new file mode 100644 index 0000000..45313a1 --- /dev/null +++ b/templates/basic/lib/hal/hal.h @@ -0,0 +1,67 @@ +#ifndef HAL_H +#define HAL_H + +#include + +// Pin modes (match Arduino constants) +#ifndef INPUT +#define INPUT 0x0 +#define OUTPUT 0x1 +#define INPUT_PULLUP 0x2 +#endif + +#ifndef LOW +#define LOW 0x0 +#define HIGH 0x1 +#endif + +// LED_BUILTIN for host builds +#ifndef LED_BUILTIN +#define LED_BUILTIN 13 +#endif + +/* + * Hardware Abstraction Layer + * + * Abstract interface over GPIO, timing, serial, and I2C. Sketch logic + * depends on this interface only -- never on Arduino.h directly. + * + * Two implementations: + * hal_arduino.h -- real hardware (included by .ino files) + * mock_hal.h -- Google Mock (included by test files) + */ +class Hal { +public: + virtual ~Hal() = default; + + // -- GPIO --------------------------------------------------------------- + virtual void pinMode(uint8_t pin, uint8_t mode) = 0; + virtual void digitalWrite(uint8_t pin, uint8_t value) = 0; + virtual uint8_t digitalRead(uint8_t pin) = 0; + virtual int analogRead(uint8_t pin) = 0; + virtual void analogWrite(uint8_t pin, int value) = 0; + + // -- Timing ------------------------------------------------------------- + virtual unsigned long millis() = 0; + virtual unsigned long micros() = 0; + virtual void delay(unsigned long ms) = 0; + virtual void delayMicroseconds(unsigned long us) = 0; + + // -- Serial ------------------------------------------------------------- + virtual void serialBegin(unsigned long baud) = 0; + virtual void serialPrint(const char* msg) = 0; + virtual void serialPrintln(const char* msg) = 0; + virtual int serialAvailable() = 0; + virtual int serialRead() = 0; + + // -- I2C ---------------------------------------------------------------- + virtual void i2cBegin() = 0; + virtual void i2cBeginTransmission(uint8_t addr) = 0; + virtual size_t i2cWrite(uint8_t data) = 0; + virtual uint8_t i2cEndTransmission() = 0; + virtual uint8_t i2cRequestFrom(uint8_t addr, uint8_t count) = 0; + virtual int i2cAvailable() = 0; + virtual int i2cRead() = 0; +}; + +#endif // HAL_H diff --git a/templates/basic/lib/hal/hal_arduino.h b/templates/basic/lib/hal/hal_arduino.h new file mode 100644 index 0000000..05dc49d --- /dev/null +++ b/templates/basic/lib/hal/hal_arduino.h @@ -0,0 +1,93 @@ +#ifndef HAL_ARDUINO_H +#define HAL_ARDUINO_H + +/* + * Real hardware implementation of the HAL. + * + * This file includes Arduino.h and Wire.h, so it can only be compiled + * by avr-gcc (via arduino-cli). It is included by .ino files only. + * + * Every method is a trivial passthrough to the real Arduino function. + * The point is not to add logic here -- it is to keep Arduino.h out + * of your application code so that code can compile on the host. + */ + +#include +#include +#include + +class ArduinoHal : public Hal { +public: + // -- GPIO --------------------------------------------------------------- + void pinMode(uint8_t pin, uint8_t mode) override { + ::pinMode(pin, mode); + } + void digitalWrite(uint8_t pin, uint8_t value) override { + ::digitalWrite(pin, value); + } + uint8_t digitalRead(uint8_t pin) override { + return ::digitalRead(pin); + } + int analogRead(uint8_t pin) override { + return ::analogRead(pin); + } + void analogWrite(uint8_t pin, int value) override { + ::analogWrite(pin, value); + } + + // -- Timing ------------------------------------------------------------- + unsigned long millis() override { + return ::millis(); + } + unsigned long micros() override { + return ::micros(); + } + void delay(unsigned long ms) override { + ::delay(ms); + } + void delayMicroseconds(unsigned long us) override { + ::delayMicroseconds(us); + } + + // -- Serial ------------------------------------------------------------- + void serialBegin(unsigned long baud) override { + Serial.begin(baud); + } + void serialPrint(const char* msg) override { + Serial.print(msg); + } + void serialPrintln(const char* msg) override { + Serial.println(msg); + } + int serialAvailable() override { + return Serial.available(); + } + int serialRead() override { + return Serial.read(); + } + + // -- I2C ---------------------------------------------------------------- + void i2cBegin() override { + Wire.begin(); + } + void i2cBeginTransmission(uint8_t addr) override { + Wire.beginTransmission(addr); + } + size_t i2cWrite(uint8_t data) override { + return Wire.write(data); + } + uint8_t i2cEndTransmission() override { + return Wire.endTransmission(); + } + uint8_t i2cRequestFrom(uint8_t addr, uint8_t count) override { + return Wire.requestFrom(addr, count); + } + int i2cAvailable() override { + return Wire.available(); + } + int i2cRead() override { + return Wire.read(); + } +}; + +#endif // HAL_ARDUINO_H diff --git a/templates/basic/test/CMakeLists.txt.tmpl b/templates/basic/test/CMakeLists.txt.tmpl new file mode 100644 index 0000000..67b6565 --- /dev/null +++ b/templates/basic/test/CMakeLists.txt.tmpl @@ -0,0 +1,47 @@ +cmake_minimum_required(VERSION 3.14) +project({{PROJECT_NAME}}_tests LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# -------------------------------------------------------------------------- +# Google Test (fetched automatically on first build) +# -------------------------------------------------------------------------- +include(FetchContent) +FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest.git + GIT_TAG v1.14.0 +) +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(googletest) + +enable_testing() + +# -------------------------------------------------------------------------- +# Include paths -- same headers the Arduino sketch uses +# -------------------------------------------------------------------------- +set(LIB_DIR ${CMAKE_SOURCE_DIR}/../lib) + +include_directories( + ${LIB_DIR}/hal + ${LIB_DIR}/app + ${CMAKE_SOURCE_DIR}/mocks +) + +# -------------------------------------------------------------------------- +# Unit tests (Google Mock) +# -------------------------------------------------------------------------- +add_executable(test_unit + test_unit.cpp +) +target_link_libraries(test_unit + GTest::gtest_main + GTest::gmock +) + +# -------------------------------------------------------------------------- +# Register with CTest +# -------------------------------------------------------------------------- +include(GoogleTest) +gtest_discover_tests(test_unit) diff --git a/templates/basic/test/mocks/mock_hal.h b/templates/basic/test/mocks/mock_hal.h new file mode 100644 index 0000000..2d3015f --- /dev/null +++ b/templates/basic/test/mocks/mock_hal.h @@ -0,0 +1,45 @@ +#ifndef MOCK_HAL_H +#define MOCK_HAL_H + +#include +#include "hal.h" + +/* + * StrictMock-friendly HAL mock for unit tests. + * + * Use this when you want to verify exact call sequences: + * EXPECT_CALL(mock, digitalWrite(13, HIGH)).Times(1); + */ +class MockHal : public Hal { +public: + // GPIO + MOCK_METHOD(void, pinMode, (uint8_t pin, uint8_t mode), (override)); + MOCK_METHOD(void, digitalWrite, (uint8_t pin, uint8_t value), (override)); + MOCK_METHOD(uint8_t, digitalRead, (uint8_t pin), (override)); + MOCK_METHOD(int, analogRead, (uint8_t pin), (override)); + MOCK_METHOD(void, analogWrite, (uint8_t pin, int value), (override)); + + // Timing + MOCK_METHOD(unsigned long, millis, (), (override)); + MOCK_METHOD(unsigned long, micros, (), (override)); + MOCK_METHOD(void, delay, (unsigned long ms), (override)); + MOCK_METHOD(void, delayMicroseconds, (unsigned long us), (override)); + + // Serial + MOCK_METHOD(void, serialBegin, (unsigned long baud), (override)); + MOCK_METHOD(void, serialPrint, (const char* msg), (override)); + MOCK_METHOD(void, serialPrintln, (const char* msg), (override)); + MOCK_METHOD(int, serialAvailable, (), (override)); + MOCK_METHOD(int, serialRead, (), (override)); + + // I2C + MOCK_METHOD(void, i2cBegin, (), (override)); + MOCK_METHOD(void, i2cBeginTransmission, (uint8_t addr), (override)); + MOCK_METHOD(size_t, i2cWrite, (uint8_t data), (override)); + MOCK_METHOD(uint8_t, i2cEndTransmission, (), (override)); + MOCK_METHOD(uint8_t, i2cRequestFrom, (uint8_t addr, uint8_t count), (override)); + MOCK_METHOD(int, i2cAvailable, (), (override)); + MOCK_METHOD(int, i2cRead, (), (override)); +}; + +#endif // MOCK_HAL_H diff --git a/templates/basic/test/mocks/sim_hal.h b/templates/basic/test/mocks/sim_hal.h new file mode 100644 index 0000000..b899f2a --- /dev/null +++ b/templates/basic/test/mocks/sim_hal.h @@ -0,0 +1,256 @@ +#ifndef SIM_HAL_H +#define SIM_HAL_H + +#include "hal.h" + +#include +#include +#include +#include +#include +#include +#include + +/* + * Simulated HAL for system tests. + * + * Unlike MockHal (which verifies call expectations), SimHal actually + * maintains state: pin values, a virtual clock, serial output capture, + * and pluggable I2C device simulators. + * + * This lets you write system tests that exercise full application logic + * against simulated hardware: + * + * SimHal sim; + * BlinkApp app(&sim); + * app.begin(); + * + * sim.setPin(2, LOW); // simulate button press + * sim.advanceMillis(600); // advance clock + * app.update(); + * + * EXPECT_EQ(sim.getPin(13), HIGH); // check LED state + */ + +// -------------------------------------------------------------------- +// I2C device simulator interface +// -------------------------------------------------------------------- +class I2cDeviceSim { +public: + virtual ~I2cDeviceSim() = default; + + // Called when master writes bytes to this device + virtual void onReceive(const uint8_t* data, size_t len) = 0; + + // Called when master requests bytes; fill response buffer + virtual size_t onRequest(uint8_t* buf, size_t max_len) = 0; +}; + +// -------------------------------------------------------------------- +// Simulated HAL +// -------------------------------------------------------------------- +class SimHal : public Hal { +public: + static const int NUM_PINS = 20; // D0-D13 + A0-A5 + + SimHal() : clock_ms_(0), clock_us_(0) { + memset(pin_modes_, 0, sizeof(pin_modes_)); + memset(pin_values_, 0, sizeof(pin_values_)); + } + + // -- GPIO --------------------------------------------------------------- + void pinMode(uint8_t pin, uint8_t mode) override { + if (pin < NUM_PINS) { + pin_modes_[pin] = mode; + // INPUT_PULLUP defaults to HIGH + if (mode == INPUT_PULLUP) { + pin_values_[pin] = HIGH; + } + } + } + + void digitalWrite(uint8_t pin, uint8_t value) override { + if (pin < NUM_PINS) { + pin_values_[pin] = value; + gpio_log_.push_back({clock_ms_, pin, value}); + } + } + + uint8_t digitalRead(uint8_t pin) override { + if (pin < NUM_PINS) return pin_values_[pin]; + return LOW; + } + + int analogRead(uint8_t pin) override { + if (pin < NUM_PINS) return analog_values_[pin]; + return 0; + } + + void analogWrite(uint8_t pin, int value) override { + if (pin < NUM_PINS) pin_values_[pin] = (value > 0) ? HIGH : LOW; + } + + // -- Timing ------------------------------------------------------------- + unsigned long millis() override { return clock_ms_; } + unsigned long micros() override { return clock_us_; } + void delay(unsigned long ms) override { advanceMillis(ms); } + void delayMicroseconds(unsigned long us) override { clock_us_ += us; } + + // -- Serial ------------------------------------------------------------- + void serialBegin(unsigned long baud) override { (void)baud; } + void serialPrint(const char* msg) override { + serial_output_ += msg; + } + void serialPrintln(const char* msg) override { + serial_output_ += msg; + serial_output_ += "\n"; + } + int serialAvailable() override { + return static_cast(serial_input_.size()); + } + int serialRead() override { + if (serial_input_.empty()) return -1; + int c = serial_input_.front(); + serial_input_.erase(serial_input_.begin()); + return c; + } + + // -- I2C ---------------------------------------------------------------- + void i2cBegin() override {} + + void i2cBeginTransmission(uint8_t addr) override { + i2c_addr_ = addr; + i2c_tx_buf_.clear(); + } + + size_t i2cWrite(uint8_t data) override { + i2c_tx_buf_.push_back(data); + return 1; + } + + uint8_t i2cEndTransmission() override { + auto it = i2c_devices_.find(i2c_addr_); + if (it == i2c_devices_.end()) return 2; // NACK on address + it->second->onReceive(i2c_tx_buf_.data(), i2c_tx_buf_.size()); + return 0; // success + } + + uint8_t i2cRequestFrom(uint8_t addr, uint8_t count) override { + i2c_rx_buf_.clear(); + auto it = i2c_devices_.find(addr); + if (it == i2c_devices_.end()) return 0; + uint8_t tmp[256]; + size_t n = it->second->onRequest(tmp, count); + for (size_t i = 0; i < n; ++i) { + i2c_rx_buf_.push_back(tmp[i]); + } + return static_cast(n); + } + + int i2cAvailable() override { + return static_cast(i2c_rx_buf_.size()); + } + + int i2cRead() override { + if (i2c_rx_buf_.empty()) return -1; + int val = i2c_rx_buf_.front(); + i2c_rx_buf_.erase(i2c_rx_buf_.begin()); + return val; + } + + // ==================================================================== + // Test control API (not part of Hal interface) + // ==================================================================== + + // -- Clock control ------------------------------------------------------ + void advanceMillis(unsigned long ms) { + clock_ms_ += ms; + clock_us_ += ms * 1000; + } + + void setMillis(unsigned long ms) { + clock_ms_ = ms; + clock_us_ = ms * 1000; + } + + // -- GPIO control ------------------------------------------------------- + void setPin(uint8_t pin, uint8_t value) { + if (pin < NUM_PINS) pin_values_[pin] = value; + } + + uint8_t getPin(uint8_t pin) const { + if (pin < NUM_PINS) return pin_values_[pin]; + return LOW; + } + + uint8_t getPinMode(uint8_t pin) const { + if (pin < NUM_PINS) return pin_modes_[pin]; + return 0; + } + + void setAnalog(uint8_t pin, int value) { + analog_values_[pin] = value; + } + + // -- GPIO history ------------------------------------------------------- + struct GpioEvent { + unsigned long timestamp_ms; + uint8_t pin; + uint8_t value; + }; + + const std::vector& gpioLog() const { return gpio_log_; } + + void clearGpioLog() { gpio_log_.clear(); } + + // Count how many times a pin was set to a specific value + int countWrites(uint8_t pin, uint8_t value) const { + int count = 0; + for (const auto& e : gpio_log_) { + if (e.pin == pin && e.value == value) ++count; + } + return count; + } + + // -- Serial control ----------------------------------------------------- + const std::string& serialOutput() const { return serial_output_; } + void clearSerialOutput() { serial_output_.clear(); } + + void injectSerialInput(const std::string& data) { + for (char c : data) { + serial_input_.push_back(static_cast(c)); + } + } + + // -- I2C device registration -------------------------------------------- + void attachI2cDevice(uint8_t addr, I2cDeviceSim* device) { + i2c_devices_[addr] = device; + } + + void detachI2cDevice(uint8_t addr) { + i2c_devices_.erase(addr); + } + +private: + // GPIO + uint8_t pin_modes_[NUM_PINS]; + uint8_t pin_values_[NUM_PINS]; + std::map analog_values_; + std::vector gpio_log_; + + // Timing + unsigned long clock_ms_; + unsigned long clock_us_; + + // Serial + std::string serial_output_; + std::vector serial_input_; + + // I2C + uint8_t i2c_addr_ = 0; + std::vector i2c_tx_buf_; + std::vector i2c_rx_buf_; + std::map i2c_devices_; +}; + +#endif // SIM_HAL_H diff --git a/templates/basic/test/run_tests.bat b/templates/basic/test/run_tests.bat new file mode 100644 index 0000000..8790a85 --- /dev/null +++ b/templates/basic/test/run_tests.bat @@ -0,0 +1,42 @@ +@echo off +setlocal + +set SCRIPT_DIR=%~dp0 +set BUILD_DIR=%SCRIPT_DIR%build + +if "%1"=="--clean" ( + if exist "%BUILD_DIR%" ( + echo Cleaning build directory... + rmdir /s /q "%BUILD_DIR%" + ) +) + +if not exist "%BUILD_DIR%\CMakeCache.txt" ( + echo Configuring (first run will fetch Google Test)... + cmake -S "%SCRIPT_DIR%" -B "%BUILD_DIR%" -DCMAKE_BUILD_TYPE=Debug + if errorlevel 1 ( + echo FAIL: cmake configure failed + exit /b 1 + ) +) + +echo Building tests... +cmake --build "%BUILD_DIR%" --parallel +if errorlevel 1 ( + echo FAIL: build failed + exit /b 1 +) + +echo. +echo Running tests... +echo. + +ctest --test-dir "%BUILD_DIR%" --output-on-failure +if errorlevel 1 ( + echo. + echo FAIL: Some tests failed. + exit /b 1 +) + +echo. +echo PASS: All tests passed. diff --git a/templates/basic/test/run_tests.sh b/templates/basic/test/run_tests.sh new file mode 100644 index 0000000..fc19fe5 --- /dev/null +++ b/templates/basic/test/run_tests.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# +# run_tests.sh -- Build and run host-side unit tests +# +# Usage: +# ./test/run_tests.sh Build and run all tests +# ./test/run_tests.sh --clean Clean rebuild +# ./test/run_tests.sh --verbose Verbose test output +# +# Prerequisites: +# cmake >= 3.14, g++ or clang++, git (for fetching gtest) +# +# First run will download Google Test (~30 seconds). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" + +# Color output +if [[ -t 1 ]]; then + RED=$'\033[0;31m'; GRN=$'\033[0;32m'; CYN=$'\033[0;36m' + BLD=$'\033[1m'; RST=$'\033[0m' +else + RED=''; GRN=''; CYN=''; BLD=''; RST='' +fi + +info() { echo -e "${CYN}[TEST]${RST} $*"; } +ok() { echo -e "${GRN}[PASS]${RST} $*"; } +die() { echo -e "${RED}[FAIL]${RST} $*" >&2; exit 1; } + +DO_CLEAN=0 +VERBOSE="" + +for arg in "$@"; do + case "$arg" in + --clean) DO_CLEAN=1 ;; + --verbose) VERBOSE="--verbose" ;; + *) die "Unknown option: $arg" ;; + esac +done + +command -v cmake &>/dev/null || die "cmake not found. Install: sudo apt install cmake" +command -v g++ &>/dev/null || command -v clang++ &>/dev/null || die "No C++ compiler found" +command -v git &>/dev/null || die "git not found (needed to fetch Google Test)" + +if [[ $DO_CLEAN -eq 1 ]] && [[ -d "$BUILD_DIR" ]]; then + info "Cleaning build directory..." + rm -rf "$BUILD_DIR" +fi + +if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then + info "Configuring (first run will fetch Google Test)..." + cmake -S "$SCRIPT_DIR" -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Debug +fi + +info "Building tests..." +cmake --build "$BUILD_DIR" --parallel + +echo "" +info "${BLD}Running tests...${RST}" +echo "" + +CTEST_ARGS=("--test-dir" "$BUILD_DIR" "--output-on-failure") +[[ -n "$VERBOSE" ]] && CTEST_ARGS+=("--verbose") + +if ctest "${CTEST_ARGS[@]}"; then + echo "" + ok "${BLD}All tests passed.${RST}" +else + echo "" + die "Some tests failed." +fi diff --git a/templates/basic/test/test_unit.cpp.tmpl b/templates/basic/test/test_unit.cpp.tmpl new file mode 100644 index 0000000..1326849 --- /dev/null +++ b/templates/basic/test/test_unit.cpp.tmpl @@ -0,0 +1,124 @@ +#include +#include + +#include "hal.h" +#include "mock_hal.h" +#include "{{PROJECT_NAME}}_app.h" + +using ::testing::_; +using ::testing::AnyNumber; +using ::testing::Return; +using ::testing::HasSubstr; + +// ============================================================================ +// Unit Tests -- verify exact HAL interactions +// ============================================================================ + +class BlinkAppUnitTest : public ::testing::Test { +protected: + void SetUp() override { + ON_CALL(mock_, millis()).WillByDefault(Return(0)); + ON_CALL(mock_, digitalRead(_)).WillByDefault(Return(HIGH)); + EXPECT_CALL(mock_, serialBegin(_)).Times(AnyNumber()); + EXPECT_CALL(mock_, serialPrintln(_)).Times(AnyNumber()); + EXPECT_CALL(mock_, millis()).Times(AnyNumber()); + } + + ::testing::NiceMock mock_; +}; + +TEST_F(BlinkAppUnitTest, BeginConfiguresPins) { + BlinkApp app(&mock_, 13, 2); + + EXPECT_CALL(mock_, pinMode(13, OUTPUT)).Times(1); + EXPECT_CALL(mock_, pinMode(2, INPUT_PULLUP)).Times(1); + EXPECT_CALL(mock_, serialBegin(115200)).Times(1); + + app.begin(); +} + +TEST_F(BlinkAppUnitTest, StartsInSlowMode) { + BlinkApp app(&mock_); + app.begin(); + + EXPECT_FALSE(app.fastMode()); + EXPECT_EQ(app.interval(), BlinkApp::SLOW_INTERVAL_MS); +} + +TEST_F(BlinkAppUnitTest, TogglesLedAfterInterval) { + BlinkApp app(&mock_); + + ON_CALL(mock_, millis()).WillByDefault(Return(0)); + app.begin(); + + ON_CALL(mock_, millis()).WillByDefault(Return(500)); + EXPECT_CALL(mock_, digitalWrite(13, _)).Times(1); + app.update(); +} + +TEST_F(BlinkAppUnitTest, DoesNotToggleBeforeInterval) { + BlinkApp app(&mock_); + + ON_CALL(mock_, millis()).WillByDefault(Return(0)); + app.begin(); + + ON_CALL(mock_, millis()).WillByDefault(Return(499)); + EXPECT_CALL(mock_, digitalWrite(_, _)).Times(0); + app.update(); +} + +TEST_F(BlinkAppUnitTest, ButtonPressSwitchesToFastMode) { + BlinkApp app(&mock_, 13, 2); + + ON_CALL(mock_, millis()).WillByDefault(Return(0)); + ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(HIGH)); + app.begin(); + + ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(LOW)); + EXPECT_CALL(mock_, serialPrintln(HasSubstr("FAST"))).Times(1); + app.update(); + + EXPECT_TRUE(app.fastMode()); + EXPECT_EQ(app.interval(), BlinkApp::FAST_INTERVAL_MS); +} + +TEST_F(BlinkAppUnitTest, SecondButtonPressReturnsToSlowMode) { + BlinkApp app(&mock_, 13, 2); + + ON_CALL(mock_, millis()).WillByDefault(Return(0)); + ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(HIGH)); + app.begin(); + + // First press: fast mode + ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(LOW)); + app.update(); + EXPECT_TRUE(app.fastMode()); + + // Release + ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(HIGH)); + app.update(); + + // Second press: back to slow + ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(LOW)); + EXPECT_CALL(mock_, serialPrintln(HasSubstr("SLOW"))).Times(1); + app.update(); + + EXPECT_FALSE(app.fastMode()); +} + +TEST_F(BlinkAppUnitTest, HoldingButtonDoesNotRepeatToggle) { + BlinkApp app(&mock_, 13, 2); + + ON_CALL(mock_, millis()).WillByDefault(Return(0)); + ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(HIGH)); + app.begin(); + + ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(LOW)); + app.update(); + EXPECT_TRUE(app.fastMode()); + + // Still held -- should NOT toggle again + app.update(); + app.update(); + EXPECT_TRUE(app.fastMode()); +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs new file mode 100644 index 0000000..01076e8 --- /dev/null +++ b/tests/integration_test.rs @@ -0,0 +1,408 @@ +use tempfile::TempDir; +use std::fs; +use std::path::Path; + +use anvil::templates::{TemplateManager, TemplateContext}; +use anvil::project::config::ProjectConfig; + +// ============================================================================ +// Template extraction tests +// ============================================================================ + +#[test] +fn test_basic_template_extracts_all_expected_files() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "test_proj".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + let count = TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + assert!(count >= 10, "Expected at least 10 files, got {}", count); +} + +#[test] +fn test_template_creates_sketch_directory() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "blink".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + let sketch_dir = tmp.path().join("blink"); + assert!(sketch_dir.is_dir(), "Sketch directory should exist"); + + let ino_file = sketch_dir.join("blink.ino"); + assert!(ino_file.exists(), "Sketch .ino file should exist"); + + // Verify the .ino content has correct includes + let content = fs::read_to_string(&ino_file).unwrap(); + assert!( + content.contains("blink_app.h"), + ".ino should include project-specific app header" + ); + assert!( + content.contains("hal_arduino.h"), + ".ino should include hal_arduino.h" + ); +} + +#[test] +fn test_template_creates_hal_files() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "sensor".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + assert!( + tmp.path().join("lib/hal/hal.h").exists(), + "hal.h should exist" + ); + assert!( + tmp.path().join("lib/hal/hal_arduino.h").exists(), + "hal_arduino.h should exist" + ); + + // Verify hal.h defines the abstract Hal class + let hal_content = fs::read_to_string(tmp.path().join("lib/hal/hal.h")).unwrap(); + assert!( + hal_content.contains("class Hal"), + "hal.h should define class Hal" + ); + assert!( + hal_content.contains("virtual void pinMode"), + "hal.h should declare pinMode" + ); +} + +#[test] +fn test_template_creates_app_header() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "my_sensor".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + let app_path = tmp.path().join("lib/app/my_sensor_app.h"); + assert!(app_path.exists(), "App header should exist with project name"); + + let content = fs::read_to_string(&app_path).unwrap(); + assert!( + content.contains("#include "), + "App header should include hal.h" + ); + assert!( + content.contains("class BlinkApp"), + "App header should define BlinkApp class" + ); +} + +#[test] +fn test_template_creates_test_infrastructure() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "blink".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + assert!( + tmp.path().join("test/CMakeLists.txt").exists(), + "CMakeLists.txt should exist" + ); + assert!( + tmp.path().join("test/test_unit.cpp").exists(), + "test_unit.cpp should exist" + ); + assert!( + tmp.path().join("test/mocks/mock_hal.h").exists(), + "mock_hal.h should exist" + ); + assert!( + tmp.path().join("test/mocks/sim_hal.h").exists(), + "sim_hal.h should exist" + ); + assert!( + tmp.path().join("test/run_tests.sh").exists(), + "run_tests.sh should exist" + ); + assert!( + tmp.path().join("test/run_tests.bat").exists(), + "run_tests.bat should exist" + ); +} + +#[test] +fn test_template_test_file_references_correct_app() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "motor_ctrl".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + let test_content = fs::read_to_string( + tmp.path().join("test/test_unit.cpp") + ).unwrap(); + assert!( + test_content.contains("motor_ctrl_app.h"), + "Test file should include project-specific app header" + ); +} + +#[test] +fn test_template_cmake_references_correct_project() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "my_bot".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + let cmake = fs::read_to_string( + tmp.path().join("test/CMakeLists.txt") + ).unwrap(); + assert!( + cmake.contains("my_bot"), + "CMakeLists.txt should contain project name" + ); +} + +#[test] +fn test_template_creates_dot_files() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "blink".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + assert!( + tmp.path().join(".gitignore").exists(), + ".gitignore should be created from _dot_ prefix" + ); + assert!( + tmp.path().join(".editorconfig").exists(), + ".editorconfig should be created" + ); + assert!( + tmp.path().join(".clang-format").exists(), + ".clang-format should be created" + ); + assert!( + tmp.path().join(".vscode/settings.json").exists(), + ".vscode/settings.json should be created" + ); +} + +#[test] +fn test_template_creates_readme() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "blink".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + let readme = tmp.path().join("README.md"); + assert!(readme.exists(), "README.md should exist"); + + let content = fs::read_to_string(&readme).unwrap(); + assert!(content.contains("blink"), "README should contain project name"); +} + +// ============================================================================ +// .anvil.toml config tests +// ============================================================================ + +#[test] +fn test_template_creates_valid_config() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "blink".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + // Should be loadable by ProjectConfig + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(config.project.name, "blink"); + assert_eq!(config.build.fqbn, "arduino:avr:uno"); + assert_eq!(config.monitor.baud, 115200); + assert!(config.build.extra_flags.contains(&"-Werror".to_string())); +} + +#[test] +fn test_config_roundtrip() { + let tmp = TempDir::new().unwrap(); + let config = ProjectConfig::new("roundtrip_test"); + config.save(tmp.path()).unwrap(); + + let loaded = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(loaded.project.name, "roundtrip_test"); + assert_eq!(loaded.build.fqbn, config.build.fqbn); + assert_eq!(loaded.monitor.baud, config.monitor.baud); + assert_eq!(loaded.build.include_dirs, config.build.include_dirs); +} + +#[test] +fn test_config_find_project_root_walks_up() { + let tmp = TempDir::new().unwrap(); + let config = ProjectConfig::new("walk_test"); + config.save(tmp.path()).unwrap(); + + // Create nested subdirectory + let deep = tmp.path().join("sketch").join("src").join("deep"); + fs::create_dir_all(&deep).unwrap(); + + let found = ProjectConfig::find_project_root(&deep).unwrap(); + assert_eq!(found, tmp.path().canonicalize().unwrap()); +} + +#[test] +fn test_config_resolve_include_flags() { + let tmp = TempDir::new().unwrap(); + fs::create_dir_all(tmp.path().join("lib/hal")).unwrap(); + fs::create_dir_all(tmp.path().join("lib/app")).unwrap(); + + let config = ProjectConfig::new("flags_test"); + let flags = config.resolve_include_flags(tmp.path()); + + assert_eq!(flags.len(), 2); + assert!(flags[0].starts_with("-I")); + assert!(flags[0].ends_with("lib/hal") || flags[0].ends_with("lib\\hal")); +} + +#[test] +fn test_config_skips_nonexistent_include_dirs() { + let tmp = TempDir::new().unwrap(); + // Don't create the directories + let config = ProjectConfig::new("missing_dirs"); + let flags = config.resolve_include_flags(tmp.path()); + assert_eq!(flags.len(), 0, "Should skip non-existent directories"); +} + +// ============================================================================ +// Full project creation test (end-to-end in temp dir) +// ============================================================================ + +#[test] +fn test_full_project_structure() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "full_test".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + // Verify the complete expected file tree + let expected_files = vec![ + ".anvil.toml", + ".gitignore", + ".editorconfig", + ".clang-format", + ".vscode/settings.json", + "README.md", + "full_test/full_test.ino", + "lib/hal/hal.h", + "lib/hal/hal_arduino.h", + "lib/app/full_test_app.h", + "test/CMakeLists.txt", + "test/test_unit.cpp", + "test/run_tests.sh", + "test/run_tests.bat", + "test/mocks/mock_hal.h", + "test/mocks/sim_hal.h", + ]; + + for f in &expected_files { + let p = tmp.path().join(f); + assert!( + p.exists(), + "Expected file missing: {} (checked {})", + f, + p.display() + ); + } +} + +#[test] +fn test_no_unicode_in_template_output() { + // Eric's rule: only ASCII characters + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "ascii_test".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + // Check all generated text files for non-ASCII + check_ascii_recursive(tmp.path()); +} + +fn check_ascii_recursive(dir: &Path) { + for entry in fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.is_dir() { + check_ascii_recursive(&path); + } else { + // Only check text files + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if matches!(ext, "h" | "cpp" | "ino" | "txt" | "sh" | "bat" | "json" | "toml" | "md") { + let content = fs::read_to_string(&path).unwrap(); + for (line_num, line) in content.lines().enumerate() { + for (col, ch) in line.chars().enumerate() { + assert!( + ch.is_ascii(), + "Non-ASCII character '{}' (U+{:04X}) at {}:{}:{} ", + ch, + ch as u32, + path.display(), + line_num + 1, + col + 1 + ); + } + } + } + } + } +} + +// ============================================================================ +// Error case tests +// ============================================================================ + +#[test] +fn test_unknown_template_fails() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "test".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + let result = TemplateManager::extract("nonexistent", tmp.path(), &ctx); + assert!(result.is_err()); +} + +#[test] +fn test_load_config_from_nonproject_fails() { + let tmp = TempDir::new().unwrap(); + let result = ProjectConfig::load(tmp.path()); + assert!(result.is_err()); +}