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:
24
.editorconfig
Normal file
24
.editorconfig
Normal 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
23
.gitattributes
vendored
Normal 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
22
.gitignore
vendored
Normal 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
987
Cargo.lock
generated
Normal 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
55
Cargo.toml
Normal 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
21
LICENSE
Normal 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
112
README.md
Normal 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
BIN
anvil_src.zip
Normal file
Binary file not shown.
1
clippy.toml
Normal file
1
clippy.toml
Normal file
@@ -0,0 +1 @@
|
||||
msrv = "1.75.0"
|
||||
3
rustfmt.toml
Normal file
3
rustfmt.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
edition = "2021"
|
||||
max_width = 100
|
||||
use_small_heuristics = "Default"
|
||||
345
src/board/mod.rs
Normal file
345
src/board/mod.rs
Normal 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
299
src/commands/build.rs
Normal 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
67
src/commands/devices.rs
Normal 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
237
src/commands/doctor.rs
Normal 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
6
src/commands/mod.rs
Normal 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
167
src/commands/monitor.rs
Normal 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
252
src/commands/new.rs
Normal 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
163
src/commands/setup.rs
Normal 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
5
src/lib.rs
Normal 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
196
src/main.rs
Normal 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
226
src/project/config.rs
Normal 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
3
src/project/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod config;
|
||||
|
||||
pub use config::ProjectConfig;
|
||||
234
src/templates/mod.rs
Normal file
234
src/templates/mod.rs
Normal 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
1
src/version.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub const ANVIL_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
74
templates/basic/README.md.tmpl
Normal file
74
templates/basic/README.md.tmpl
Normal 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
|
||||
```
|
||||
28
templates/basic/__name__/__name__.ino.tmpl
Normal file
28
templates/basic/__name__/__name__.ino.tmpl
Normal 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();
|
||||
}
|
||||
12
templates/basic/_dot_anvil.toml.tmpl
Normal file
12
templates/basic/_dot_anvil.toml.tmpl
Normal 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
|
||||
8
templates/basic/_dot_clang-format
Normal file
8
templates/basic/_dot_clang-format
Normal file
@@ -0,0 +1,8 @@
|
||||
BasedOnStyle: Google
|
||||
IndentWidth: 4
|
||||
ColumnLimit: 100
|
||||
AllowShortFunctionsOnASingleLine: Inline
|
||||
AllowShortIfStatementsOnASingleLine: Never
|
||||
BreakBeforeBraces: Attach
|
||||
PointerAlignment: Left
|
||||
SortIncludes: false
|
||||
21
templates/basic/_dot_editorconfig
Normal file
21
templates/basic/_dot_editorconfig
Normal 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
|
||||
10
templates/basic/_dot_gitignore
Normal file
10
templates/basic/_dot_gitignore
Normal file
@@ -0,0 +1,10 @@
|
||||
# Build artifacts
|
||||
test/build/
|
||||
|
||||
# IDE
|
||||
.vscode/.browse*
|
||||
.vscode/*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
21
templates/basic/_dot_vscode/settings.json
Normal file
21
templates/basic/_dot_vscode/settings.json
Normal 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
|
||||
}
|
||||
88
templates/basic/lib/app/__name___app.h.tmpl
Normal file
88
templates/basic/lib/app/__name___app.h.tmpl
Normal 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
|
||||
67
templates/basic/lib/hal/hal.h
Normal file
67
templates/basic/lib/hal/hal.h
Normal 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
|
||||
93
templates/basic/lib/hal/hal_arduino.h
Normal file
93
templates/basic/lib/hal/hal_arduino.h
Normal 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
|
||||
47
templates/basic/test/CMakeLists.txt.tmpl
Normal file
47
templates/basic/test/CMakeLists.txt.tmpl
Normal 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)
|
||||
45
templates/basic/test/mocks/mock_hal.h
Normal file
45
templates/basic/test/mocks/mock_hal.h
Normal 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
|
||||
256
templates/basic/test/mocks/sim_hal.h
Normal file
256
templates/basic/test/mocks/sim_hal.h
Normal 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
|
||||
42
templates/basic/test/run_tests.bat
Normal file
42
templates/basic/test/run_tests.bat
Normal 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.
|
||||
73
templates/basic/test/run_tests.sh
Normal file
73
templates/basic/test/run_tests.sh
Normal 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
|
||||
124
templates/basic/test/test_unit.cpp.tmpl
Normal file
124
templates/basic/test/test_unit.cpp.tmpl
Normal 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
408
tests/integration_test.rs
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user