aboutsummaryrefslogtreecommitdiff
path: root/mingling_cli
diff options
context:
space:
mode:
author魏曹先生 <1992414357@qq.com>2026-08-09 13:52:26 +0800
committer魏曹先生 <1992414357@qq.com>2026-08-09 13:52:26 +0800
commit3b5da73b9b5401d426bae20a6bd735dab26b6b8e (patch)
treef9960e3c3213e44f9f023c5cb7867fb891ca3b1c /mingling_cli
parent76e42ad0b79d012581629268879620261575d1a1 (diff)
feat: add project template expansion with rule-based generation
Implement `mling proj-init` to generate projects from zip templates with checklist-based configuration. Extracts template archive, moves checklist for user editing, and expands `tmpl_`-prefixed files using `just_template` with boolean rule evaluation from `rule.toml` for file hides and display toggles.
Diffstat (limited to 'mingling_cli')
-rw-r--r--mingling_cli/Cargo.lock592
-rw-r--r--mingling_cli/Cargo.toml8
-rw-r--r--mingling_cli/help/help.txt4
-rw-r--r--mingling_cli/src/bin/cli.rs3
-rw-r--r--mingling_cli/src/proj_mgr.rs3
-rw-r--r--mingling_cli/src/proj_mgr/cmd_proj_init.rs356
-rw-r--r--mingling_cli/src/proj_mgr/rule_solver.rs481
7 files changed, 1439 insertions, 8 deletions
diff --git a/mingling_cli/Cargo.lock b/mingling_cli/Cargo.lock
index ddfe01c..e5993b4 100644
--- a/mingling_cli/Cargo.lock
+++ b/mingling_cli/Cargo.lock
@@ -3,6 +3,23 @@
version = 4
[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
name = "annotate-snippets"
version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -20,6 +37,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
+name = "arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
+dependencies = [
+ "derive_arbitrary",
+]
+
+[[package]]
name = "arg-picker"
version = "0.1.1"
dependencies = [
@@ -43,12 +69,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
+name = "bzip2"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
+dependencies = [
+ "bzip2-sys",
+]
+
+[[package]]
+name = "bzip2-sys"
+version = "0.1.13+1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
+[[package]]
name = "camino"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -83,12 +149,34 @@ dependencies = [
]
[[package]]
+name = "cc"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
+[[package]]
name = "colored"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -98,6 +186,61 @@ dependencies = [
]
[[package]]
+name = "constant_time_eq"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -133,6 +276,29 @@ dependencies = [
]
[[package]]
+name = "deflate64"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "derive_arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -164,6 +330,17 @@ dependencies = [
]
[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -185,6 +362,17 @@ dependencies = [
]
[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.2",
+]
+
+[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -201,12 +389,38 @@ dependencies = [
]
[[package]]
+name = "find-msvc-tools"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -218,12 +432,46 @@ dependencies = [
]
[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+]
+
+[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -240,12 +488,41 @@ dependencies = [
]
[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
+name = "jobserver"
+version = "0.1.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
+dependencies = [
+ "getrandom 0.4.3",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
+]
+
+[[package]]
name = "just_fmt"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -259,9 +536,9 @@ checksum = "6170dccbc3ea15dfb7f2da964097f814aba1dd8f746d4ffc56f33245c38e6d96"
[[package]]
name = "just_template"
-version = "0.2.0"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fb99a3c1dee7299c57b26ef927f15535da0fbc93d6deb1d1114cae1337be4fb"
+checksum = "f2a56a31287e7397340dd8d9997c8fd936ee68b27cb3f090ece2984e8a84d5bc"
dependencies = [
"just_fmt 0.1.2",
"just_template_macros",
@@ -303,6 +580,33 @@ dependencies = [
]
[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "lzma-rs"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
+dependencies = [
+ "byteorder",
+ "crc",
+]
+
+[[package]]
+name = "lzma-sys"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -347,6 +651,7 @@ dependencies = [
"syn 3.0.2",
"tokio",
"toml_edit 0.25.13+spec-1.1.0",
+ "zip",
]
[[package]]
@@ -379,6 +684,16 @@ dependencies = [
]
[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
name = "mio"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -390,6 +705,18 @@ dependencies = [
]
[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -419,12 +746,34 @@ dependencies = [
]
[[package]]
+name = "pbkdf2"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
+dependencies = [
+ "digest",
+ "hmac",
+]
+
+[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -443,6 +792,18 @@ dependencies = [
]
[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -457,12 +818,18 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
- "getrandom",
+ "getrandom 0.2.17",
"libredox",
"thiserror",
]
[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -531,6 +898,23 @@ dependencies = [
]
[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -541,6 +925,12 @@ dependencies = [
]
[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -563,6 +953,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -605,6 +1001,25 @@ dependencies = [
]
[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -711,6 +1126,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -723,12 +1144,72 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -762,7 +1243,112 @@ dependencies = [
]
[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "xz2"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
+dependencies = [
+ "lzma-sys",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zip"
+version = "2.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
+dependencies = [
+ "aes",
+ "arbitrary",
+ "bzip2",
+ "constant_time_eq",
+ "crc32fast",
+ "crossbeam-utils",
+ "deflate64",
+ "displaydoc",
+ "flate2",
+ "getrandom 0.3.4",
+ "hmac",
+ "indexmap",
+ "lzma-rs",
+ "memchr",
+ "pbkdf2",
+ "sha1",
+ "thiserror",
+ "time",
+ "xz2",
+ "zeroize",
+ "zopfli",
+ "zstd",
+]
+
+[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+
+[[package]]
+name = "zopfli"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
+
+[[package]]
+name = "zstd"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.16+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
diff --git a/mingling_cli/Cargo.toml b/mingling_cli/Cargo.toml
index 2bb3fd3..a9b484a 100644
--- a/mingling_cli/Cargo.toml
+++ b/mingling_cli/Cargo.toml
@@ -61,13 +61,19 @@ colored = "3.1.1"
dirs = "6.0.0"
semver = "1.0.28"
+# Code gen
+just_template = "0.2.1"
+
+# Compress
+zip = "2"
+
[build-dependencies]
# Configure & Serialization
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
# Code gen
-just_template = "0.2.0"
+just_template = "0.2.1"
[profile.release]
opt-level = "z"
diff --git a/mingling_cli/help/help.txt b/mingling_cli/help/help.txt
index 0fff769..9e35194 100644
--- a/mingling_cli/help/help.txt
+++ b/mingling_cli/help/help.txt
@@ -21,8 +21,8 @@ __ ra-lint-clippy *Run* `mling lint` *and* `cargo clippy`*, an
__ explain <LINT> *Explain the meaning of the specified Lint*
__ **PROJECT MANAGE:**
-__ proj-new <NAME> **[NOT_IMPL]** *Create a project*
-__ proj-init **[NOT_IMPL]** *Initialize a project*
+__ proj-init <TEMPLATE> *Initialize a project*
+__ class-add <CLASS> <PARAMS...> **[NOT_IMPL]** *Create a Class of the specified type in the project*
__ **PACKAGE MANAGE:**
__ install *Install the project to the Mingling package list*
diff --git a/mingling_cli/src/bin/cli.rs b/mingling_cli/src/bin/cli.rs
index 6f7556e..2e6c972 100644
--- a/mingling_cli/src/bin/cli.rs
+++ b/mingling_cli/src/bin/cli.rs
@@ -1,4 +1,4 @@
-use mingling::setup::{ExitCodeSetup, picker::HelpFlagSetup};
+use mingling::setup::{DirectoryEnvironmentSetup, ExitCodeSetup, picker::HelpFlagSetup};
use mingling_cli::{
ThisProgram, linter::registry::LintRegistrySetup, metadata::MinglingMetadataSetup,
pkg_mgr::PackageManagerSetup,
@@ -11,6 +11,7 @@ async fn main() {
// Setups
program.with_setup(HelpFlagSetup::default());
program.with_setup(ExitCodeSetup::default());
+ program.with_setup(DirectoryEnvironmentSetup::default());
program.with_setup(MinglingMetadataSetup);
program.with_setup(LintRegistrySetup);
diff --git a/mingling_cli/src/proj_mgr.rs b/mingling_cli/src/proj_mgr.rs
index 8b13789..509f37d 100644
--- a/mingling_cli/src/proj_mgr.rs
+++ b/mingling_cli/src/proj_mgr.rs
@@ -1 +1,2 @@
-
+pub mod cmd_proj_init;
+pub mod rule_solver;
diff --git a/mingling_cli/src/proj_mgr/cmd_proj_init.rs b/mingling_cli/src/proj_mgr/cmd_proj_init.rs
new file mode 100644
index 0000000..3dfad9c
--- /dev/null
+++ b/mingling_cli/src/proj_mgr/cmd_proj_init.rs
@@ -0,0 +1,356 @@
+use std::{
+ collections::HashMap,
+ fs::{self, File},
+ io,
+ path::{Path, PathBuf},
+};
+
+use just_template::Template;
+use mingling::{
+ Grouped, RenderResult, Routable,
+ macros::{arg, chain, command, metadata, pack, pack_err, r_println, renderer, routeify},
+ metadata::Description,
+ picker::{EntryPicker, value::FilePath},
+ res::ResCurrentDir,
+};
+
+use crate::{Entry, Next, eprintln_cargo, hprintln_cargo, println_cargo};
+
+use super::rule_solver::{eval_rule, parse_checklist, parse_rules, resolve_answers};
+
+/// The name of the checklist file the user edits and we re-read on generate.
+const CHECKLIST_FILENAME: &str = "checklist.toml";
+/// The name of the rules file declaring default params, display blocks and hides.
+const RULE_FILENAME: &str = "rule.toml";
+/// The directory under the project root where the template cache lives.
+const CACHE_DIR_NAME: &str = "tmpl-cache";
+/// The internal `.mling` directory name inside the template archive (with `tmpl_` prefix).
+const TEMPLATE_MLING_DIR: &str = "tmpl_.mling";
+/// Prefix marking directories/files that participate in expansion.
+const TMPL_PREFIX: &str = "tmpl_";
+
+pack!(StateProjectGenerate = ());
+pack!(StateProjectChecklistReady = Vec<String>);
+
+/// Result of the checklist phase: the extracted checklist handed to the user.
+#[derive(Debug, Default, Grouped)]
+pub struct ResultProjectChecklistReady {
+ pub checklist: PathBuf,
+}
+
+/// Result of the generate phase: files produced (and hidden) by expansion.
+#[derive(Debug, Default, Grouped)]
+pub struct ResultProjectGenerate {
+ pub generated: Vec<PathBuf>,
+ pub hidden: Vec<PathBuf>,
+}
+
+pack_err!(ErrorTemplateZipNotProvided = ());
+pack_err!(ErrorUnzipFailed = String);
+pack_err!(ErrorChecklistMissing = String);
+pack_err!(ErrorRuleParseFailed = String);
+pack_err!(ErrorTemplateExpandFailed = String);
+
+#[command(node = "proj-init", routeify)]
+pub fn proj_init(args: Entry, cwd: &ResCurrentDir) -> Next {
+ // Check if the checklist.toml file exists in the current directory
+ if cwd.join(CHECKLIST_FILENAME).exists() {
+ StateProjectGenerate::new(()).into()
+ } else {
+ StateProjectChecklistReady::new(args.inner).into()
+ }
+}
+
+/// Phase 1: extract the user-provided template archive into
+/// `./.mling/tmpl-cache/` and hand the checklist over for editing.
+#[chain(routeify)]
+pub fn handle_state_proj_checklist_ready(
+ args: StateProjectChecklistReady,
+ cwd: &ResCurrentDir,
+) -> Next {
+ let tmpl_file: Option<FilePath> = args.pick(&arg![Option<FilePath>]).to_result()?;
+
+ // Validate
+ let Some(tmpl_file) = tmpl_file else {
+ return ErrorTemplateZipNotProvided::new(()).to_chain();
+ };
+
+ // Extract the file into the .mling directory under the current directory; create it if it doesn't exist
+ let tmpl_cache = cwd.join(".mling").join(CACHE_DIR_NAME);
+ fs::create_dir_all(&tmpl_cache).map_err(|e| {
+ ErrorUnzipFailed::new(format!("failed to create {}: {e}", tmpl_cache.display()))
+ })?;
+ unzip_to(&tmpl_file, &tmpl_cache).map_err(ErrorUnzipFailed::new)?;
+
+ // Move the internal checklist.toml to ./ for the user to fill in
+ let checklist_src = tmpl_cache.join(CHECKLIST_FILENAME);
+ if !checklist_src.is_file() {
+ return ErrorChecklistMissing::new(format!(
+ "no checklist.toml found inside {}",
+ tmpl_file.display()
+ ))
+ .to_chain();
+ }
+ let checklist_dst = cwd.join(CHECKLIST_FILENAME);
+ fs::rename(&checklist_src, &checklist_dst).map_err(|e| {
+ ErrorUnzipFailed::new(format!(
+ "failed to move checklist.toml to {}: {e}",
+ checklist_dst.display()
+ ))
+ })?;
+
+ ResultProjectChecklistReady {
+ checklist: checklist_dst,
+ }
+ .to_chain()
+}
+
+/// Phase 2: expand the cached template with the checklist answers, driven by
+/// `rule.toml` display/hide rules, then clean up the cache.
+#[chain(routeify)]
+pub fn handle_state_project_generate(_: StateProjectGenerate, cwd: &ResCurrentDir) -> Next {
+ let tmpl_cache = cwd.join(".mling").join(CACHE_DIR_NAME);
+ if !tmpl_cache.is_dir() {
+ return ErrorChecklistMissing::new(format!(
+ "template cache not found at {}; run `mling proj-init` with a template archive first",
+ tmpl_cache.display()
+ ))
+ .to_chain();
+ }
+
+ // Read the user-filled checklist.toml
+ let checklist_path = cwd.join(CHECKLIST_FILENAME);
+ let checklist_content = fs::read_to_string(&checklist_path).map_err(|e| {
+ ErrorChecklistMissing::new(format!("failed to read {}: {e}", checklist_path.display()))
+ })?;
+ let answers = parse_checklist(&checklist_content)
+ .map_err(|e| ErrorRuleParseFailed::new(format!("invalid checklist.toml: {e}")))?;
+
+ // Read rule.toml (template rules)
+ let rule_content = fs::read_to_string(tmpl_cache.join(RULE_FILENAME))
+ .map_err(|e| ErrorRuleParseFailed::new(format!("failed to read rule.toml: {e}")))?;
+ let rules = parse_rules(&rule_content)
+ .map_err(|e| ErrorRuleParseFailed::new(format!("invalid rule.toml: {e}")))?;
+
+ // Compute final answers from checklist values + defaults declared in rule.toml
+ let answers = resolve_answers(&answers, &rules);
+
+ // Compute display block toggles based on rules: checklist values + rules whose display condition is true
+
+ let mut params: HashMap<String, String> = answers.clone();
+ for display in &rules.display {
+ if eval_rule(&display.rule, &answers) {
+ params.insert(display.name.clone(), String::new());
+ }
+ }
+
+ // Expand all tmpl_* entries to the project root
+ let mut generated = Vec::new();
+ expand_tree(&tmpl_cache, cwd, &params, &mut generated)
+ .map_err(ErrorTemplateExpandFailed::new)?;
+
+ // hide-file: delete the corresponding generated file when the rule is true
+
+ let mut hidden = Vec::new();
+ for hide in &rules.hide_files {
+ if !eval_rule(&hide.rule, &answers) {
+ continue;
+ }
+ let target = cwd.join(hide.file.trim_start_matches("./"));
+ remove_path(&target).map_err(|e| {
+ ErrorTemplateExpandFailed::new(format!("failed to hide {}: {e}", target.display()))
+ })?;
+ hidden.push(target);
+ }
+
+ // Clean up the cache
+ fs::remove_dir_all(&tmpl_cache).map_err(|e| {
+ ErrorTemplateExpandFailed::new(format!("failed to remove {}: {e}", tmpl_cache.display()))
+ })?;
+
+ // Project generated; remove the temporary checklist file
+ remove_path(&checklist_path).map_err(|e| {
+ ErrorTemplateExpandFailed::new(format!(
+ "failed to remove {}: {e}",
+ checklist_path.display()
+ ))
+ })?;
+
+ ResultProjectGenerate { generated, hidden }.to_chain()
+}
+
+/// Extract a ZIP archive into `dest`, guarding against path traversal.
+fn unzip_to(zip_path: &Path, dest: &Path) -> Result<(), String> {
+ let file = File::open(zip_path).map_err(|e| e.to_string())?;
+ let mut archive = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
+
+ for i in 0..archive.len() {
+ let mut entry = archive.by_index(i).map_err(|e| e.to_string())?;
+ let Some(rel) = entry.enclosed_name() else {
+ return Err(format!("illegal path in archive: {}", entry.name()));
+ };
+ let out_path = dest.join(rel);
+ if entry.is_dir() {
+ fs::create_dir_all(&out_path).map_err(|e| e.to_string())?;
+ } else {
+ if let Some(parent) = out_path.parent() {
+ fs::create_dir_all(parent).map_err(|e| e.to_string())?;
+ }
+ let mut out = File::create(&out_path).map_err(|e| e.to_string())?;
+ io::copy(&mut entry, &mut out).map_err(|e| e.to_string())?;
+ }
+ }
+ Ok(())
+}
+
+/// Recursively expand the template cache into the project root.
+///
+/// Every path component with a `tmpl_` prefix is stripped for the target path
+/// (`tmpl_src/tmpl_main.rs` -> `src/main.rs`). `tmpl_`-prefixed files are
+/// rendered through `just_template` with the resolved params; other files are
+/// skipped. `tmpl_.mling` is copied into `./.mling` without rendering.
+fn expand_tree(
+ src_root: &Path,
+ dst_root: &Path,
+ params: &HashMap<String, String>,
+ generated: &mut Vec<PathBuf>,
+) -> Result<(), String> {
+ for entry in fs::read_dir(src_root).map_err(|e| e.to_string())? {
+ let entry = entry.map_err(|e| e.to_string())?;
+ let name = entry.file_name().to_string_lossy().into_owned();
+ let src = entry.path();
+
+ // The template's own .mling tree is copied verbatim (minus `tmpl_` prefixes).
+ if name == TEMPLATE_MLING_DIR && src.is_dir() {
+ copy_tree(&src, &dst_root.join(".mling")).map_err(|e| e.to_string())?;
+ continue;
+ }
+
+ let target_name = name
+ .strip_prefix(TMPL_PREFIX)
+ .map(str::to_owned)
+ .unwrap_or(name.clone());
+ let dst = dst_root.join(&target_name);
+
+ if src.is_dir() {
+ fs::create_dir_all(&dst).map_err(|e| e.to_string())?;
+ expand_tree(&src, &dst, params, generated)?;
+ } else if src.is_file() && name.starts_with(TMPL_PREFIX) {
+ let content = fs::read_to_string(&src).map_err(|e| e.to_string())?;
+ let mut tmpl = Template::from(content);
+ for (key, value) in params {
+ tmpl.insert_param(key.clone(), value.clone());
+ }
+ let expanded = tmpl
+ .expand()
+ .ok_or_else(|| format!("failed to expand template: {}", src.display()))?;
+ if let Some(parent) = dst.parent() {
+ fs::create_dir_all(parent).map_err(|e| e.to_string())?;
+ }
+ fs::write(&dst, expanded).map_err(|e| e.to_string())?;
+ generated.push(dst);
+ }
+ }
+ Ok(())
+}
+
+/// Copy a directory tree into `dst`, stripping the `tmpl_` prefix from every
+/// component name (e.g. `tmpl_command` -> `command`).
+fn copy_tree(src: &Path, dst: &Path) -> io::Result<()> {
+ fs::create_dir_all(dst)?;
+ for entry in fs::read_dir(src)? {
+ let entry = entry?;
+ let name = entry.file_name().to_string_lossy().into_owned();
+ let target_name = name
+ .strip_prefix(TMPL_PREFIX)
+ .map(str::to_owned)
+ .unwrap_or(name);
+ let from = entry.path();
+ let to = dst.join(&target_name);
+ if from.is_dir() {
+ copy_tree(&from, &to)?;
+ } else if from.is_file() {
+ fs::copy(&from, &to)?;
+ }
+ }
+ Ok(())
+}
+
+/// Remove a file or directory, ignoring "not found".
+fn remove_path(path: &Path) -> io::Result<()> {
+ if path.is_dir() {
+ fs::remove_dir_all(path)
+ } else if path.is_file() {
+ fs::remove_file(path)
+ } else {
+ Ok(())
+ }
+}
+
+#[renderer]
+pub fn render_result_project_checklist_ready(result: ResultProjectChecklistReady) -> RenderResult {
+ let mut r = RenderResult::new();
+ r_println!(r, "Template extracted.");
+ r_println!(r, "");
+ hprintln_cargo!(
+ r,
+ "Fill out {} and run `mling proj-init` again to generate the project.",
+ result.checklist.display()
+ );
+ r
+}
+
+#[renderer]
+pub fn render_result_project_generate(result: ResultProjectGenerate) -> RenderResult {
+ let mut r = RenderResult::new();
+ for file in &result.generated {
+ println_cargo!(r, "Generated: {}", file.display());
+ }
+ for file in &result.hidden {
+ println_cargo!(r, "Hidden: {}", file.display());
+ }
+ r
+}
+
+#[renderer]
+pub fn render_error_template_zip_not_provided(_err: ErrorTemplateZipNotProvided) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(
+ r,
+ "no template archive provided; pass the path to a mingling template zip file"
+ );
+ r
+}
+
+#[renderer]
+pub fn render_error_unzip_failed(err: ErrorUnzipFailed) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(r, "failed to extract template: {}", err.info);
+ r
+}
+
+#[renderer]
+pub fn render_error_checklist_missing(err: ErrorChecklistMissing) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(r, "{}", err.info);
+ r
+}
+
+#[renderer]
+pub fn render_error_rule_parse_failed(err: ErrorRuleParseFailed) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(r, "{}", err.info);
+ r
+}
+
+#[renderer]
+pub fn render_error_template_expand_failed(err: ErrorTemplateExpandFailed) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(r, "{}", err.info);
+ r
+}
+
+#[metadata(EntryProjInit)]
+pub fn desc_proj_init() -> Description {
+ "Guided creation of a Mingling project".into()
+}
diff --git a/mingling_cli/src/proj_mgr/rule_solver.rs b/mingling_cli/src/proj_mgr/rule_solver.rs
new file mode 100644
index 0000000..0e00458
--- /dev/null
+++ b/mingling_cli/src/proj_mgr/rule_solver.rs
@@ -0,0 +1,481 @@
+//! Parsing and evaluation of `rule.toml` and `checklist.toml`.
+//!
+//! `rule.toml` drives template generation:
+//! - `[[display]]` entries map a display-block name to a boolean rule; when the
+//! rule evaluates to true, the corresponding `??? >>> NAME` block is enabled.
+//! - `[[hide-file]]` entries map a generated file path to a boolean rule; when
+//! the rule evaluates to true, the generated file is removed.
+//!
+//! `checklist.toml` holds the user's answers as key/value pairs and provides
+//! the variables the rules are evaluated against.
+
+use std::collections::HashMap;
+
+use toml_edit::DocumentMut;
+
+/// A `[[display]]` rule: enable display block `name` when `rule` is true.
+#[derive(Debug, Clone)]
+pub struct DisplayRule {
+ /// Display block name, e.g. `ASYNC`, `PARSER_PICKER`.
+ pub name: String,
+ /// Boolean expression, e.g. `tokio || async_std`.
+ pub rule: String,
+}
+
+/// A `[[hide-file]]` rule: hide `file` when `rule` is true.
+#[derive(Debug, Clone)]
+pub struct HideFileRule {
+ /// File path relative to the project root, e.g. `./build.rs`.
+ pub file: String,
+ /// Boolean expression, e.g. `!completion && !pathf`.
+ pub rule: String,
+}
+
+/// A `[[user.*]]` entry describing a checklist answer and its default.
+///
+/// Checklist keys are declared under `[[user.input]]`, `[[user.toggle]]` or
+/// `[[user.selection]]`; toggles and selections may carry a `default` value
+/// that applies when the user leaves the key unset in `checklist.toml`.
+#[derive(Debug, Clone)]
+pub struct UserRule {
+ /// Checklist key name, e.g. `program_name`, `tokio`, `parser`.
+ pub name: String,
+ /// Default value (stringified), if declared in `rule.toml`.
+ pub default: Option<String>,
+}
+
+/// All rules parsed from `rule.toml`.
+#[derive(Debug, Default, Clone)]
+pub struct TemplateRules {
+ pub users: Vec<UserRule>,
+ pub display: Vec<DisplayRule>,
+ pub hide_files: Vec<HideFileRule>,
+}
+
+/// Parse `checklist.toml` into a flat map of answers.
+///
+/// Values are stringified: strings keep their content, booleans become
+/// `"true"` / `"false"`, numbers keep their decimal representation.
+/// Commented-out (disabled) keys are ignored by the TOML parser.
+pub fn parse_checklist(content: &str) -> Result<HashMap<String, String>, String> {
+ let doc = content.parse::<DocumentMut>().map_err(|e| e.to_string())?;
+ let mut answers = HashMap::new();
+ for (key, item) in doc.iter() {
+ let toml_edit::Item::Value(value) = item else {
+ continue;
+ };
+ let stringified = match value {
+ toml_edit::Value::String(s) => s.value().clone(),
+ toml_edit::Value::Boolean(b) => b.value().to_string(),
+ toml_edit::Value::Integer(i) => i.value().to_string(),
+ toml_edit::Value::Float(f) => f.value().to_string(),
+ // Arrays, inline tables and datetimes are not valid checklist answers.
+ _ => continue,
+ };
+ answers.insert(key.to_string(), stringified);
+ }
+ Ok(answers)
+}
+
+/// Parse `rule.toml` into display and hide-file rules.
+pub fn parse_rules(content: &str) -> Result<TemplateRules, String> {
+ let doc = content.parse::<DocumentMut>().map_err(|e| e.to_string())?;
+ let mut rules = TemplateRules::default();
+
+ // `[[user.input]]`, `[[user.toggle]]`, `[[user.selection]]` parse into a
+ // `user` table whose keys (`input`/`toggle`/`selection`) hold the arrays.
+ if let Some(user_table) = doc.get("user").and_then(|item| item.as_table()) {
+ for kind in ["input", "toggle", "selection"] {
+ let Some(tables) = user_table
+ .get(kind)
+ .and_then(|item| item.as_array_of_tables())
+ else {
+ continue;
+ };
+ for table in tables {
+ let name = table
+ .get("name")
+ .and_then(|v| v.as_str())
+ .ok_or("[[user.*]] entry is missing `name`")?
+ .to_string();
+ let default =
+ table
+ .get("default")
+ .and_then(|v| v.as_value())
+ .and_then(|v| match v {
+ toml_edit::Value::Boolean(b) => Some(b.value().to_string()),
+ toml_edit::Value::String(s) => Some(s.value().clone()),
+ _ => None,
+ });
+ rules.users.push(UserRule { name, default });
+ }
+ }
+ }
+
+ if let Some(tables) = doc
+ .get("display")
+ .and_then(|item| item.as_array_of_tables())
+ {
+ for table in tables {
+ let name = table
+ .get("name")
+ .and_then(|v| v.as_str())
+ .ok_or("[[display]] entry is missing `name`")?
+ .to_string();
+ let rule = table
+ .get("rule")
+ .and_then(|v| v.as_str())
+ .ok_or("[[display]] entry is missing `rule`")?
+ .to_string();
+ rules.display.push(DisplayRule { name, rule });
+ }
+ }
+
+ if let Some(tables) = doc
+ .get("hide-file")
+ .and_then(|item| item.as_array_of_tables())
+ {
+ for table in tables {
+ let file = table
+ .get("file")
+ .and_then(|v| v.as_str())
+ .ok_or("[[hide-file]] entry is missing `file`")?
+ .to_string();
+ let rule = table
+ .get("rule")
+ .and_then(|v| v.as_str())
+ .ok_or("[[hide-file]] entry is missing `rule`")?
+ .to_string();
+ rules.hide_files.push(HideFileRule { file, rule });
+ }
+ }
+
+ Ok(rules)
+}
+
+/// Merge the checklist answers with the defaults declared in `rule.toml`.
+///
+/// A key the user left unset in `checklist.toml` falls back to its declared
+/// default (e.g. `pathf` defaults to `true`); keys the user filled in always
+/// win. The merged map is what rules are evaluated against.
+pub fn resolve_answers(
+ answers: &HashMap<String, String>,
+ rules: &TemplateRules,
+) -> HashMap<String, String> {
+ let mut merged = answers.clone();
+ for user in &rules.users {
+ if let Some(default) = &user.default
+ && !merged.contains_key(&user.name)
+ {
+ merged.insert(user.name.clone(), default.clone());
+ }
+ }
+ merged
+}
+
+/// Evaluate a boolean rule expression against the checklist answers.
+///
+/// Supported syntax:
+/// - bare identifier: `tokio` — true when the key exists with a truthy value
+/// - comparison: `parser == clap`
+/// - operators: `!`, `&&`, `||`
+/// - parentheses for grouping
+pub fn eval_rule(rule: &str, answers: &HashMap<String, String>) -> bool {
+ let mut parser = RuleParser::new(rule, answers);
+ parser.parse_or().unwrap_or(false)
+}
+
+/// A key is truthy when it is present with a non-empty, non-`"false"` value.
+fn is_truthy(key: &str, answers: &HashMap<String, String>) -> bool {
+ matches!(answers.get(key), Some(value) if !value.is_empty() && value != "false")
+}
+
+/// Recursive-descent parser for `rule.toml` boolean expressions.
+struct RuleParser<'a> {
+ chars: Vec<char>,
+ pos: usize,
+ answers: &'a HashMap<String, String>,
+}
+
+impl<'a> RuleParser<'a> {
+ fn new(rule: &str, answers: &'a HashMap<String, String>) -> Self {
+ Self {
+ chars: rule.chars().collect(),
+ pos: 0,
+ answers,
+ }
+ }
+
+ /// Skips whitespace, then consumes `c` if it matches.
+ fn eat(&mut self, c: char) -> bool {
+ self.skip_ws();
+ if self.chars.get(self.pos) == Some(&c) {
+ self.pos += 1;
+ true
+ } else {
+ false
+ }
+ }
+
+ fn skip_ws(&mut self) {
+ while self.chars.get(self.pos).is_some_and(|c| c.is_whitespace()) {
+ self.pos += 1;
+ }
+ }
+
+ /// `or := and ('||' and)*`
+ fn parse_or(&mut self) -> Option<bool> {
+ let mut value = self.parse_and()?;
+ while self.eat('|') && self.eat('|') {
+ let rhs = self.parse_and()?;
+ value |= rhs;
+ }
+ Some(value)
+ }
+
+ /// `and := unary ('&&' unary)*`
+ fn parse_and(&mut self) -> Option<bool> {
+ let mut value = self.parse_unary()?;
+ while self.eat('&') && self.eat('&') {
+ let rhs = self.parse_unary()?;
+ value &= rhs;
+ }
+ Some(value)
+ }
+
+ /// `unary := '!' unary | primary`
+ fn parse_unary(&mut self) -> Option<bool> {
+ if self.eat('!') {
+ return Some(!self.parse_unary()?);
+ }
+ self.parse_primary()
+ }
+
+ /// `primary := '(' or ')' | ident ('==' ident)?`
+ fn parse_primary(&mut self) -> Option<bool> {
+ if self.eat('(') {
+ let value = self.parse_or()?;
+ self.eat(')');
+ return Some(value);
+ }
+ let ident = self.parse_ident()?;
+ if self.eat('=') && self.eat('=') {
+ let other = self.parse_ident()?;
+ return Some(self.answers.get(&ident).map(String::as_str) == Some(other.as_str()));
+ }
+ Some(is_truthy(&ident, self.answers))
+ }
+
+ fn parse_ident(&mut self) -> Option<String> {
+ self.skip_ws();
+ let start = self.pos;
+ while self
+ .chars
+ .get(self.pos)
+ .is_some_and(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
+ {
+ self.pos += 1;
+ }
+ if self.pos > start {
+ Some(self.chars[start..self.pos].iter().collect())
+ } else {
+ None
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn checklist_parses_flat_values() {
+ let content = r#"
+program_name = "my-cli"
+parser = "picker"
+exit_code = true
+completion = true
+"#;
+ let answers = parse_checklist(content).unwrap();
+ assert_eq!(answers.get("program_name").unwrap(), "my-cli");
+ assert_eq!(answers.get("parser").unwrap(), "picker");
+ assert_eq!(answers.get("exit_code").unwrap(), "true");
+ assert_eq!(answers.get("completion").unwrap(), "true");
+ }
+
+ #[test]
+ fn checklist_ignores_commented_keys() {
+ let content = r#"
+# tokio = true
+completion = true
+"#;
+ let answers = parse_checklist(content).unwrap();
+ assert!(!answers.contains_key("tokio"));
+ assert!(answers.contains_key("completion"));
+ }
+
+ #[test]
+ fn rules_parse_display_and_hide_file() {
+ let content = r#"
+[[display]]
+name = "TOKIO"
+rule = "tokio"
+
+[[display]]
+name = "ASYNC"
+rule = "tokio || async_std"
+
+[[display]]
+name = "PARSER_PICKER"
+rule = "use_parser && parser == picker"
+
+[[hide-file]]
+file = "./build.rs"
+rule = "!completion && !pathf"
+"#;
+ let rules = parse_rules(content).unwrap();
+ assert_eq!(rules.display.len(), 3);
+ assert_eq!(rules.display[0].name, "TOKIO");
+ assert_eq!(rules.display[0].rule, "tokio");
+ assert_eq!(rules.display[1].rule, "tokio || async_std");
+ assert_eq!(rules.display[2].rule, "use_parser && parser == picker");
+ assert_eq!(rules.hide_files.len(), 1);
+ assert_eq!(rules.hide_files[0].file, "./build.rs");
+ assert_eq!(rules.hide_files[0].rule, "!completion && !pathf");
+ }
+
+ #[test]
+ fn rules_parse_user_defaults() {
+ let content = r#"
+[[user.toggle]]
+name = "pathf"
+default = true
+
+[[user.toggle]]
+name = "tokio"
+
+[[user.selection]]
+name = "parser"
+option = [ "clap", "builtin", "picker" ]
+"#;
+ let rules = parse_rules(content).unwrap();
+ assert_eq!(rules.users.len(), 3);
+ assert_eq!(rules.users[0].name, "pathf");
+ assert_eq!(rules.users[0].default.as_deref(), Some("true"));
+ assert_eq!(rules.users[1].name, "tokio");
+ assert_eq!(rules.users[1].default, None);
+ assert_eq!(rules.users[2].name, "parser");
+ assert_eq!(rules.users[2].default, None);
+ }
+
+ #[test]
+ fn resolve_answers_applies_declared_defaults() {
+ let content = r#"
+[[user.toggle]]
+name = "pathf"
+default = true
+
+[[user.toggle]]
+name = "completion"
+"#;
+ let rules = parse_rules(content).unwrap();
+
+ // completion answered, pathf left unset -> pathf falls back to true
+ let mut answers = HashMap::new();
+ answers.insert("completion".into(), "true".into());
+ let merged = resolve_answers(&answers, &rules);
+ assert_eq!(merged.get("completion").unwrap(), "true");
+ assert_eq!(merged.get("pathf").unwrap(), "true");
+
+ // user-provided value always wins over the default
+ let mut answers = HashMap::new();
+ answers.insert("pathf".into(), "false".into());
+ let merged = resolve_answers(&answers, &rules);
+ assert_eq!(merged.get("pathf").unwrap(), "false");
+ }
+
+ #[test]
+ fn toggle_semantics_match_checklist_states() {
+ // rule.toml: `pathf` defaults to true, `tokio` has no default.
+ let content = r#"
+[[user.toggle]]
+name = "pathf"
+default = true
+
+[[user.toggle]]
+name = "tokio"
+"#;
+ let rules = parse_rules(content).unwrap();
+
+ // 1. Commented out (`# key = true`): key absent -> default applies;
+ // keys without a default stay absent (falsy).
+ let merged = resolve_answers(&HashMap::new(), &rules);
+ assert_eq!(merged.get("pathf").unwrap(), "true");
+ assert!(!merged.contains_key("tokio"));
+ assert!(eval_rule("pathf", &merged));
+ assert!(!eval_rule("tokio", &merged));
+
+ // 2. Explicit `key = true`.
+ let mut answers = HashMap::new();
+ answers.insert("pathf".into(), "true".into());
+ answers.insert("tokio".into(), "true".into());
+ let merged = resolve_answers(&answers, &rules);
+ assert!(eval_rule("pathf", &merged));
+ assert!(eval_rule("tokio", &merged));
+
+ // 3. Explicit `key = false` overrides the declared default.
+ let mut answers = HashMap::new();
+ answers.insert("pathf".into(), "false".into());
+ let merged = resolve_answers(&answers, &rules);
+ assert_eq!(merged.get("pathf").unwrap(), "false");
+ assert!(!eval_rule("pathf", &merged));
+ }
+
+ #[test]
+ fn eval_bare_identifier() {
+ let mut answers = HashMap::new();
+ answers.insert("tokio".into(), "true".into());
+ assert!(eval_rule("tokio", &answers));
+ assert!(!eval_rule("async_std", &answers));
+ }
+
+ #[test]
+ fn eval_false_value_is_falsy() {
+ let mut answers = HashMap::new();
+ answers.insert("pathf".into(), "false".into());
+ assert!(!eval_rule("pathf", &answers));
+
+ // Empty string is also falsy.
+ answers.insert("empty".into(), "".into());
+ assert!(!eval_rule("empty", &answers));
+ }
+
+ #[test]
+ fn eval_boolean_operators() {
+ let mut answers = HashMap::new();
+ answers.insert("tokio".into(), "true".into());
+ answers.insert("completion".into(), "true".into());
+ answers.insert("pathf".into(), "false".into());
+
+ assert!(eval_rule("tokio || async_std", &answers));
+ assert!(!eval_rule("async_std && tokio", &answers));
+ assert!(eval_rule("!async_std", &answers));
+ // completion is true, so `!completion && !pathf` is false
+ assert!(!eval_rule("!completion && !pathf", &answers));
+ // with neither key present the same rule becomes true
+ assert!(eval_rule("!completion && !pathf", &HashMap::new()));
+ assert!(eval_rule("(tokio || async_std) && completion", &answers));
+ assert!(!eval_rule("(async_std || tokio) && !completion", &answers));
+ }
+
+ #[test]
+ fn eval_equality_comparison() {
+ let mut answers = HashMap::new();
+ answers.insert("parser".into(), "picker".into());
+ answers.insert("use_parser".into(), "true".into());
+
+ assert!(eval_rule("parser == picker", &answers));
+ assert!(!eval_rule("parser == clap", &answers));
+ assert!(eval_rule("use_parser && parser == picker", &answers));
+ }
+}