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