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
This commit is contained in:
Eric Ratliff
2026-02-15 11:16:17 -06:00
commit 3298844399
41 changed files with 4866 additions and 0 deletions

24
.editorconfig Normal file
View File

@@ -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

23
.gitattributes vendored Normal file
View File

@@ -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

22
.gitignore vendored Normal file
View File

@@ -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/

987
Cargo.lock generated Normal file
View File

@@ -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"

55
Cargo.toml Normal file
View File

@@ -0,0 +1,55 @@
[package]
name = "anvil"
version = "1.0.0"
edition = "2021"
authors = ["Eric Ratliff <eric@nxlearn.net>"]
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

21
LICENSE Normal file
View File

@@ -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.

112
README.md Normal file
View File

@@ -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).

BIN
anvil_src.zip Normal file

Binary file not shown.

1
clippy.toml Normal file
View File

@@ -0,0 +1 @@
msrv = "1.75.0"

3
rustfmt.toml Normal file
View File

@@ -0,0 +1,3 @@
edition = "2021"
max_width = 100
use_small_heuristics = "Default"

345
src/board/mod.rs Normal file
View File

@@ -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<DetectedPort>,
}
#[derive(Debug, Deserialize)]
struct DetectedPort {
#[serde(default)]
port: Option<PortEntry>,
#[serde(default)]
matching_boards: Option<Vec<MatchingBoard>>,
}
#[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<serde_json::Value>,
}
#[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<PortInfo> {
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<Vec<PortInfo>> {
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<PortInfo> {
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<String> {
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<PathBuf> {
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");
}
}

299
src/commands/build.rs Normal file
View File

@@ -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<u32>,
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<String> = 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<PathBuf> {
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<String> {
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!();
}

67
src/commands/devices.rs Normal file
View File

@@ -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(())
}

237
src/commands/doctor.rs Normal file
View File

@@ -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<String>,
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
}
}

6
src/commands/mod.rs Normal file
View File

@@ -0,0 +1,6 @@
pub mod new;
pub mod doctor;
pub mod setup;
pub mod devices;
pub mod build;
pub mod monitor;

167
src/commands/monitor.rs Normal file
View File

@@ -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<u32>,
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()
}
}

252
src/commands/new.rs Normal file
View File

@@ -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 <project-name>");
println!(" anvil new <project-name> --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());
}
}

163
src/commands/setup.rs Normal file
View File

@@ -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");
}

5
src/lib.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod version;
pub mod commands;
pub mod project;
pub mod board;
pub mod templates;

196
src/main.rs Normal file
View File

@@ -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 <eric@nxlearn.net>")]
#[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<String>,
/// Template to use (basic)
#[arg(long, short = 't', value_name = "TEMPLATE")]
template: Option<String>,
/// 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<String>,
/// Serial monitor baud rate
#[arg(short, long)]
baud: Option<u32>,
/// Override Fully Qualified Board Name
#[arg(long)]
fqbn: Option<String>,
},
/// Upload cached build artifacts (no recompile)
Upload {
/// Path to sketch directory
sketch: String,
/// Serial port (auto-detected if omitted)
#[arg(short, long)]
port: Option<String>,
/// Show full avrdude output
#[arg(long)]
verbose: bool,
/// Override Fully Qualified Board Name
#[arg(long)]
fqbn: Option<String>,
},
/// Open serial monitor
Monitor {
/// Serial port (auto-detected if omitted)
#[arg(short, long)]
port: Option<String>,
/// Baud rate (default: from project config or 115200)
#[arg(short, long)]
baud: Option<u32>,
/// 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 <name>\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!();
}

226
src/project/config.rs Normal file
View File

@@ -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<String>,
pub extra_flags: Vec<String>,
}
#[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<Self> {
let config_path = project_root.join(CONFIG_FILENAME);
if !config_path.exists() {
bail!(
"Not an Anvil project (missing {}).\n\
Create one with: anvil new <name>",
CONFIG_FILENAME
);
}
let contents = fs::read_to_string(&config_path)
.context(format!("Failed to read {}", config_path.display()))?;
let 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<PathBuf> {
let mut dir = if start.is_absolute() {
start.to_path_buf()
} else {
std::env::current_dir()?.join(start)
};
for _ in 0..10 {
if dir.join(CONFIG_FILENAME).exists() {
return Ok(dir);
}
match dir.parent() {
Some(parent) => dir = parent.to_path_buf(),
None => break,
}
}
bail!(
"No {} found in {} or any parent directory.\n\
Create a project with: anvil new <name>",
CONFIG_FILENAME,
start.display()
);
}
/// Resolve include directories to absolute paths relative to project root.
pub fn resolve_include_flags(&self, project_root: &Path) -> Vec<String> {
let mut flags = Vec::new();
for dir in &self.build.include_dirs {
let abs = project_root.join(dir);
if abs.is_dir() {
flags.push(format!("-I{}", abs.display()));
}
}
flags
}
/// Build the full extra_flags string for arduino-cli.
pub fn extra_flags_string(&self, project_root: &Path) -> String {
let mut parts = self.resolve_include_flags(project_root);
for flag in &self.build.extra_flags {
parts.push(flag.clone());
}
parts.join(" ")
}
}
impl Default for ProjectConfig {
fn default() -> Self {
Self::new("untitled")
}
}
/// Return the Anvil home directory (~/.anvil).
pub fn anvil_home() -> Result<PathBuf> {
let home = dirs::home_dir()
.context("Could not determine home directory")?;
let anvil_dir = home.join(".anvil");
fs::create_dir_all(&anvil_dir)?;
Ok(anvil_dir)
}
/// Return the build cache directory (~/.anvil/builds).
pub fn build_cache_dir() -> Result<PathBuf> {
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"));
}
}

3
src/project/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod config;
pub use config::ProjectConfig;

234
src/templates/mod.rs Normal file
View File

@@ -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<TemplateInfo> {
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<usize> {
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<usize> {
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<String> = 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"
);
}
}

1
src/version.rs Normal file
View File

@@ -0,0 +1 @@
pub const ANVIL_VERSION: &str = env!("CARGO_PKG_VERSION");

View File

@@ -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
```

View File

@@ -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 <hal_arduino.h>
#include <{{PROJECT_NAME}}_app.h>
static ArduinoHal hw;
static BlinkApp app(&hw);
void setup() {
app.begin();
}
void loop() {
app.update();
}

View File

@@ -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

View File

@@ -0,0 +1,8 @@
BasedOnStyle: Google
IndentWidth: 4
ColumnLimit: 100
AllowShortFunctionsOnASingleLine: Inline
AllowShortIfStatementsOnASingleLine: Never
BreakBeforeBraces: Attach
PointerAlignment: Left
SortIncludes: false

View File

@@ -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

View File

@@ -0,0 +1,10 @@
# Build artifacts
test/build/
# IDE
.vscode/.browse*
.vscode/*.log
# OS
.DS_Store
Thumbs.db

View File

@@ -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
}

View File

@@ -0,0 +1,88 @@
#ifndef APP_H
#define APP_H
#include <hal.h>
/*
* 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

View File

@@ -0,0 +1,67 @@
#ifndef HAL_H
#define HAL_H
#include <stdint.h>
// 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

View File

@@ -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 <Arduino.h>
#include <Wire.h>
#include <hal.h>
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

View File

@@ -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)

View File

@@ -0,0 +1,45 @@
#ifndef MOCK_HAL_H
#define MOCK_HAL_H
#include <gmock/gmock.h>
#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

View File

@@ -0,0 +1,256 @@
#ifndef SIM_HAL_H
#define SIM_HAL_H
#include "hal.h"
#include <cstdio>
#include <cstring>
#include <functional>
#include <map>
#include <queue>
#include <string>
#include <vector>
/*
* 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<int>(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<uint8_t>(n);
}
int i2cAvailable() override {
return static_cast<int>(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<GpioEvent>& 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<uint8_t>(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<uint8_t, int> analog_values_;
std::vector<GpioEvent> gpio_log_;
// Timing
unsigned long clock_ms_;
unsigned long clock_us_;
// Serial
std::string serial_output_;
std::vector<uint8_t> serial_input_;
// I2C
uint8_t i2c_addr_ = 0;
std::vector<uint8_t> i2c_tx_buf_;
std::vector<uint8_t> i2c_rx_buf_;
std::map<uint8_t, I2cDeviceSim*> i2c_devices_;
};
#endif // SIM_HAL_H

View File

@@ -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.

View File

@@ -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

View File

@@ -0,0 +1,124 @@
#include <gtest/gtest.h>
#include <gmock/gmock.h>
#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<MockHal> 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());
}

408
tests/integration_test.rs Normal file
View File

@@ -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 <hal.h>"),
"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());
}