Compare commits
9 Commits
070e338c5c
...
5b0dbd30fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b0dbd30fd | ||
|
|
38f07ded1d | ||
|
|
3d9c28f7f7 | ||
|
|
30b5b4797e | ||
|
|
39e5c35a28 | ||
|
|
f7f7fec13b | ||
|
|
25e779a390 | ||
|
|
f4d3a6472b | ||
|
|
2e75e2106c |
475
Cargo.lock
generated
475
Cargo.lock
generated
@ -146,6 +146,15 @@ dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloca"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@ -491,6 +500,15 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-polyfill"
|
||||
version = "1.0.3"
|
||||
@ -933,12 +951,6 @@ dependencies = [
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cassowary"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
@ -1257,9 +1269,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.8.1"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
|
||||
checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
@ -1340,7 +1352,7 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3"
|
||||
dependencies = [
|
||||
"crossterm 0.29.0",
|
||||
"crossterm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1379,25 +1391,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.5.1"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
|
||||
checksum = "4d883447757bb0ee46f233e9dc22eb84d93a9508c9b868687b274fc431d886bf"
|
||||
dependencies = [
|
||||
"alloca",
|
||||
"anes",
|
||||
"cast",
|
||||
"ciborium",
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"is-terminal",
|
||||
"itertools 0.10.5",
|
||||
"itertools 0.13.0",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"oorandom",
|
||||
"page_size",
|
||||
"plotters",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"tinytemplate",
|
||||
"walkdir",
|
||||
@ -1405,12 +1416,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "criterion-plot"
|
||||
version = "0.5.0"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||
checksum = "ed943f81ea2faa8dcecbbfa50164acf95d555afec96a27871663b300e387b2e4"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools 0.10.5",
|
||||
"itertools 0.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1426,7 +1437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51360853ebbeb3df20c76c82aecf43d387a62860f1a59ba65ab51f00eea85aad"
|
||||
dependencies = [
|
||||
"crokey-proc_macros",
|
||||
"crossterm 0.29.0",
|
||||
"crossterm",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"strict",
|
||||
@ -1438,7 +1449,7 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bf1a727caeb5ee5e0a0826a97f205a9cf84ee964b0b48239fef5214a00ae439"
|
||||
dependencies = [
|
||||
"crossterm 0.29.0",
|
||||
"crossterm",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strict",
|
||||
@ -1501,22 +1512,6 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"crossterm_winapi",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix 0.38.44",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.29.0"
|
||||
@ -1560,6 +1555,16 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "csscolorparser"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf"
|
||||
dependencies = [
|
||||
"lab",
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.35.0"
|
||||
@ -1698,6 +1703,12 @@ version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
|
||||
|
||||
[[package]]
|
||||
name = "deltae"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.5"
|
||||
@ -1977,6 +1988,15 @@ version = "3.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
|
||||
|
||||
[[package]]
|
||||
name = "euclid"
|
||||
version = "0.22.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
@ -2011,6 +2031,16 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
|
||||
dependencies = [
|
||||
"bit-set 0.5.3",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.16.2"
|
||||
@ -2051,6 +2081,17 @@ version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"thiserror 1.0.69",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-crate"
|
||||
version = "0.6.3"
|
||||
@ -2066,6 +2107,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
|
||||
|
||||
[[package]]
|
||||
name = "finl_unicode"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.4.2"
|
||||
@ -3179,7 +3226,7 @@ checksum = "2628910d0114e9139056161d8644a2026be7b117f8498943f9437748b04c9e0a"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"chrono",
|
||||
"crossterm 0.29.0",
|
||||
"crossterm",
|
||||
"dyn-clone",
|
||||
"fuzzy-matcher",
|
||||
"tempfile",
|
||||
@ -3207,7 +3254,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c619cdaa30bb84088963968bee12a45ea5fbbf355f2c021bcd15589f5ca494a"
|
||||
dependencies = [
|
||||
"num_cpus",
|
||||
"ordered-float",
|
||||
"ordered-float 3.9.2",
|
||||
"parking_lot",
|
||||
"rand 0.8.5",
|
||||
"rayon",
|
||||
@ -3276,17 +3323,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi 0.5.2",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_ci"
|
||||
version = "1.2.0"
|
||||
@ -3385,6 +3421,17 @@ dependencies = [
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kasuari"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"portable-atomic",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.1.5"
|
||||
@ -3414,6 +3461,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lab"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f"
|
||||
|
||||
[[package]]
|
||||
name = "lalrpop"
|
||||
version = "0.20.2"
|
||||
@ -3576,6 +3629,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "line-clipping"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linfa-linalg"
|
||||
version = "0.1.0"
|
||||
@ -3713,6 +3775,16 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mac_address"
|
||||
version = "1.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
|
||||
dependencies = [
|
||||
"nix 0.29.0",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
@ -3871,6 +3943,21 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memmem"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miette"
|
||||
version = "5.10.0"
|
||||
@ -4165,6 +4252,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -4268,7 +4356,7 @@ version = "0.109.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3b777faf7c5180fe5d7f67d83c44fd14138d91f2938a36494ed6ac66b7160f3"
|
||||
dependencies = [
|
||||
"fancy-regex",
|
||||
"fancy-regex 0.16.2",
|
||||
"log",
|
||||
"nu-experimental",
|
||||
"nu-glob",
|
||||
@ -4363,7 +4451,7 @@ dependencies = [
|
||||
"chrono-humanize",
|
||||
"dirs",
|
||||
"dirs-sys",
|
||||
"fancy-regex",
|
||||
"fancy-regex 0.16.2",
|
||||
"heck 0.5.0",
|
||||
"indexmap 2.12.1",
|
||||
"log",
|
||||
@ -4418,9 +4506,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f8eb43c29cc5bce85f87defdadc2cca964fa434d808af37036a7cb78f3c68e9"
|
||||
dependencies = [
|
||||
"byteyarn",
|
||||
"crossterm 0.29.0",
|
||||
"crossterm",
|
||||
"crossterm_winapi",
|
||||
"fancy-regex",
|
||||
"fancy-regex 0.16.2",
|
||||
"lean_string",
|
||||
"log",
|
||||
"lscolors",
|
||||
@ -4459,6 +4547,17 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-format"
|
||||
version = "0.4.4"
|
||||
@ -4498,6 +4597,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
@ -4667,6 +4775,15 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
@ -4716,6 +4833,16 @@ version = "4.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
|
||||
|
||||
[[package]]
|
||||
name = "page_size"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@ -5477,25 +5604,89 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.29.0"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
|
||||
checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc"
|
||||
dependencies = [
|
||||
"instability",
|
||||
"ratatui-core",
|
||||
"ratatui-crossterm",
|
||||
"ratatui-macros",
|
||||
"ratatui-termwiz",
|
||||
"ratatui-widgets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cassowary",
|
||||
"compact_str",
|
||||
"crossterm 0.28.1",
|
||||
"hashbrown 0.16.1",
|
||||
"indoc",
|
||||
"instability",
|
||||
"itertools 0.13.0",
|
||||
"lru 0.12.5",
|
||||
"paste",
|
||||
"strum 0.26.3",
|
||||
"itertools 0.14.0",
|
||||
"kasuari",
|
||||
"lru 0.16.2",
|
||||
"strum 0.27.2",
|
||||
"thiserror 2.0.17",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-crossterm"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossterm",
|
||||
"instability",
|
||||
"ratatui-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-macros"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4"
|
||||
dependencies = [
|
||||
"ratatui-core",
|
||||
"ratatui-widgets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-termwiz"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c"
|
||||
dependencies = [
|
||||
"ratatui-core",
|
||||
"termwiz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-widgets"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"hashbrown 0.16.1",
|
||||
"indoc",
|
||||
"instability",
|
||||
"itertools 0.14.0",
|
||||
"line-clipping",
|
||||
"ratatui-core",
|
||||
"strum 0.27.2",
|
||||
"time",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rawpointer"
|
||||
version = "0.2.1"
|
||||
@ -6967,7 +7158,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"fancy-regex",
|
||||
"fancy-regex 0.16.2",
|
||||
"flate2",
|
||||
"fnv",
|
||||
"once_cell",
|
||||
@ -7293,6 +7484,69 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "terminfo"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"nom 7.1.3",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termios"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termwiz"
|
||||
version = "0.23.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.10.0",
|
||||
"fancy-regex 0.11.0",
|
||||
"filedescriptor",
|
||||
"finl_unicode",
|
||||
"fixedbitset 0.4.2",
|
||||
"hex",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"memmem",
|
||||
"nix 0.29.0",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"ordered-float 4.6.0",
|
||||
"pest",
|
||||
"pest_derive",
|
||||
"phf",
|
||||
"sha2",
|
||||
"signal-hook",
|
||||
"siphasher",
|
||||
"terminfo",
|
||||
"termios",
|
||||
"thiserror 1.0.69",
|
||||
"ucd-trie",
|
||||
"unicode-segmentation",
|
||||
"vtparse",
|
||||
"wezterm-bidi",
|
||||
"wezterm-blob-leases",
|
||||
"wezterm-color-types",
|
||||
"wezterm-dynamic",
|
||||
"wezterm-input-types",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.16.2"
|
||||
@ -7360,7 +7614,9 @@ checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde",
|
||||
"time-core",
|
||||
@ -7860,6 +8116,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"toml 0.9.10+spec-1.1.0",
|
||||
"tracing-subscriber",
|
||||
"typedialog-core",
|
||||
"unic-langid",
|
||||
]
|
||||
@ -7959,7 +8216,7 @@ dependencies = [
|
||||
"bincode",
|
||||
"chrono",
|
||||
"criterion",
|
||||
"crossterm 0.29.0",
|
||||
"crossterm",
|
||||
"dialoguer",
|
||||
"dirs",
|
||||
"encrypt",
|
||||
@ -8032,6 +8289,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"toml 0.9.10+spec-1.1.0",
|
||||
"tracing-subscriber",
|
||||
"typedialog-core",
|
||||
"unic-langid",
|
||||
]
|
||||
@ -8045,6 +8303,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"toml 0.9.10+spec-1.1.0",
|
||||
"tracing-subscriber",
|
||||
"typedialog-core",
|
||||
"unic-langid",
|
||||
]
|
||||
@ -8178,13 +8437,13 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-truncate"
|
||||
version = "1.1.0"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||
checksum = "8fbf03860ff438702f3910ca5f28f8dac63c1c11e7efb5012b8b175493606330"
|
||||
dependencies = [
|
||||
"itertools 0.13.0",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.1.14",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -8287,6 +8546,7 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
|
||||
dependencies = [
|
||||
"atomic",
|
||||
"getrandom 0.3.4",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
@ -8332,6 +8592,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vtparse"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
@ -8523,6 +8792,78 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wezterm-bidi"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"wezterm-dynamic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wezterm-blob-leases"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"mac_address",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wezterm-color-types"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296"
|
||||
dependencies = [
|
||||
"csscolorparser",
|
||||
"deltae",
|
||||
"lazy_static",
|
||||
"wezterm-dynamic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wezterm-dynamic"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac"
|
||||
dependencies = [
|
||||
"log",
|
||||
"ordered-float 4.6.0",
|
||||
"strsim",
|
||||
"thiserror 1.0.69",
|
||||
"wezterm-dynamic-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wezterm-dynamic-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wezterm-input-types"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"euclid",
|
||||
"lazy_static",
|
||||
"serde",
|
||||
"wezterm-dynamic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wide"
|
||||
version = "0.7.33"
|
||||
|
||||
@ -65,7 +65,7 @@ colored = "3"
|
||||
rpassword = "7.4"
|
||||
|
||||
# TUI Backend (ratatui)
|
||||
ratatui = "0.29"
|
||||
ratatui = "0.30"
|
||||
crossterm = "0.29"
|
||||
atty = "0.2"
|
||||
|
||||
@ -88,11 +88,11 @@ petgraph = "0.8"
|
||||
surrealdb = { version = "2.4", features = ["kv-mem"] }
|
||||
|
||||
# Misc
|
||||
tempfile = "3.23"
|
||||
tempfile = "3.24"
|
||||
|
||||
# Testing & Benchmarking
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
proptest = "1.4"
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
proptest = "1.9"
|
||||
|
||||
# TypeAgent dependencies
|
||||
nickel-lang-core = "0.16"
|
||||
|
||||
@ -24,6 +24,7 @@ async-trait.workspace = true
|
||||
tera = { workspace = true, optional = true }
|
||||
tempfile.workspace = true
|
||||
dirs.workspace = true # For config path resolution
|
||||
tracing.workspace = true # Logging framework
|
||||
|
||||
# i18n (optional)
|
||||
fluent = { workspace = true, optional = true }
|
||||
@ -50,7 +51,6 @@ axum = { workspace = true, optional = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
tower = { workspace = true, optional = true }
|
||||
tower-http = { workspace = true, optional = true }
|
||||
tracing = { workspace = true, optional = true }
|
||||
tracing-subscriber = { workspace = true, optional = true }
|
||||
futures = { workspace = true, optional = true }
|
||||
|
||||
@ -76,7 +76,7 @@ criterion.workspace = true
|
||||
default = ["cli", "i18n", "templates"]
|
||||
cli = ["inquire", "dialoguer", "rpassword"]
|
||||
tui = ["ratatui", "crossterm", "atty"]
|
||||
web = ["axum", "tokio", "tower", "tower-http", "tracing", "tracing-subscriber", "futures"]
|
||||
web = ["axum", "tokio", "tower", "tower-http", "tracing-subscriber", "futures"]
|
||||
i18n = ["fluent", "fluent-bundle", "unic-langid", "sys-locale"]
|
||||
templates = ["tera"]
|
||||
nushell = ["nu-protocol", "nu-plugin"]
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use criterion::{criterion_group, criterion_main, Criterion};
|
||||
use std::hint::black_box;
|
||||
use typedialog_core::form_parser::parse_toml;
|
||||
use typedialog_core::nickel::MetadataParser;
|
||||
|
||||
|
||||
@ -535,11 +535,102 @@ async fn index_handler(State(state): State<Arc<WebFormState>>) -> impl IntoRespo
|
||||
script.textContent = content;
|
||||
document.body.appendChild(script);
|
||||
}});
|
||||
|
||||
// After form is loaded and scripts executed, filter dependent options
|
||||
// This ensures primary_language only shows languages selected in detected_languages
|
||||
setTimeout(function() {{
|
||||
if (typeof filterDependentOptions === 'function') {{
|
||||
filterDependentOptions();
|
||||
}}
|
||||
}}, 100);
|
||||
}});
|
||||
}});
|
||||
|
||||
// Dynamic options filtering based on data-options-from
|
||||
function filterDependentOptions() {{
|
||||
// First, update all hidden fields for multiselect from their checkboxes
|
||||
document.querySelectorAll('[id^="values_"]').forEach(hiddenField => {{
|
||||
const fieldName = hiddenField.id.replace('values_', '');
|
||||
const checkboxes = document.querySelectorAll('[data-checkbox-group="' + fieldName + '"]:checked');
|
||||
const values = Array.from(checkboxes).map(cb => cb.value);
|
||||
hiddenField.value = values.join(',');
|
||||
}});
|
||||
|
||||
// Find all select fields with data-options-from attribute
|
||||
document.querySelectorAll('select[data-options-from]').forEach(targetSelect => {{
|
||||
const sourceFieldName = targetSelect.getAttribute('data-options-from');
|
||||
|
||||
// Store original options if not already stored
|
||||
if (!targetSelect.dataset.allOptions) {{
|
||||
const allOptions = Array.from(targetSelect.options).map(opt => ({{
|
||||
value: opt.value,
|
||||
text: opt.textContent
|
||||
}}));
|
||||
targetSelect.dataset.allOptions = JSON.stringify(allOptions);
|
||||
}}
|
||||
|
||||
// Find the source field (could be multiselect checkboxes or select)
|
||||
const sourceHiddenField = document.getElementById('values_' + sourceFieldName);
|
||||
const sourceSelect = document.querySelector('select[data-fieldname="' + sourceFieldName + '"]');
|
||||
|
||||
let selectedValues = [];
|
||||
|
||||
if (sourceHiddenField) {{
|
||||
// Multiselect with checkboxes
|
||||
const valuesStr = sourceHiddenField.value;
|
||||
selectedValues = valuesStr ? valuesStr.split(',').map(v => v.trim()) : [];
|
||||
}} else if (sourceSelect) {{
|
||||
// Regular select
|
||||
selectedValues = [sourceSelect.value];
|
||||
}}
|
||||
|
||||
// Get all original options
|
||||
const allOptions = JSON.parse(targetSelect.dataset.allOptions);
|
||||
|
||||
// Filter options to only include those in selectedValues
|
||||
const filteredOptions = allOptions.filter(opt => selectedValues.includes(opt.value));
|
||||
|
||||
// Save current value - check both selected option and current value
|
||||
let currentValue = targetSelect.value;
|
||||
if (!currentValue) {{
|
||||
// If no value selected yet, check for option with 'selected' attribute
|
||||
const selectedOption = targetSelect.querySelector('option[selected]');
|
||||
if (selectedOption) {{
|
||||
currentValue = selectedOption.value;
|
||||
}}
|
||||
}}
|
||||
|
||||
// Clear and repopulate options
|
||||
targetSelect.innerHTML = '';
|
||||
|
||||
// Determine which value should be selected
|
||||
let valueToSelect = '';
|
||||
if (currentValue && filteredOptions.some(opt => opt.value === currentValue)) {{
|
||||
valueToSelect = currentValue;
|
||||
}} else if (filteredOptions.length > 0) {{
|
||||
valueToSelect = filteredOptions[0].value;
|
||||
}}
|
||||
|
||||
// Rebuild options and mark the selected one
|
||||
filteredOptions.forEach(opt => {{
|
||||
const option = document.createElement('option');
|
||||
option.value = opt.value;
|
||||
option.textContent = opt.text;
|
||||
if (opt.value === valueToSelect) {{
|
||||
option.selected = true;
|
||||
}}
|
||||
targetSelect.appendChild(option);
|
||||
}});
|
||||
}});
|
||||
}}
|
||||
|
||||
// Reactive form field updates
|
||||
document.addEventListener('change', function(e) {{
|
||||
// First, handle dynamic options filtering
|
||||
if (e.target.matches('input[type=checkbox][data-checkbox-group], select')) {{
|
||||
filterDependentOptions();
|
||||
}}
|
||||
|
||||
if (e.target.matches('select, input[type=checkbox], input[type=radio]')) {{
|
||||
const form = document.querySelector('form#complete-form');
|
||||
if (!form) return;
|
||||
@ -559,6 +650,8 @@ async fn index_handler(State(state): State<Arc<WebFormState>>) -> impl IntoRespo
|
||||
const values = Array.from(checkboxes).map(cb => cb.value);
|
||||
hiddenField.value = values.join(',');
|
||||
}});
|
||||
// Re-run option filtering after dynamic update
|
||||
filterDependentOptions();
|
||||
}})
|
||||
.catch(err => console.error('Failed to update form:', err));
|
||||
}}
|
||||
@ -834,6 +927,159 @@ async fn submit_field_handler(
|
||||
(StatusCode::OK, headers, Json(json!({"success": true})))
|
||||
}
|
||||
|
||||
/// Render success HTML for normal (non-roundtrip) mode
|
||||
#[cfg(feature = "web")]
|
||||
fn render_success_html(results: &HashMap<String, Value>) -> String {
|
||||
let field_count = results.len();
|
||||
|
||||
let fields_html = if field_count > 0 {
|
||||
let mut html = String::from("<div style='margin: 20px 0;'>");
|
||||
html.push_str("<h3 style='margin: 0 0 10px 0;'>📋 Form Results:</h3>");
|
||||
html.push_str("<div style='background: #1e1e1e; padding: 15px; border-radius: 4px; font-family: monospace; max-height: 400px; overflow-y: auto;'>");
|
||||
|
||||
let mut sorted_fields: Vec<_> = results.iter().collect();
|
||||
sorted_fields.sort_by_key(|(k, _)| k.as_str());
|
||||
|
||||
for (field_name, value) in sorted_fields {
|
||||
let value_str = match value {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Array(arr) => {
|
||||
let items: Vec<String> = arr
|
||||
.iter()
|
||||
.map(|v| match v {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.collect();
|
||||
format!("[{}]", items.join(", "))
|
||||
}
|
||||
Value::Null => "(empty)".to_string(),
|
||||
Value::Object(_) => "{...}".to_string(),
|
||||
};
|
||||
|
||||
html.push_str(&format!(
|
||||
r#"<div style="margin: 8px 0; padding: 8px; background: #2d2d30; border-radius: 3px;">
|
||||
<div style="color: #dcdcaa; font-weight: bold;">{}</div>
|
||||
<div style="margin-left: 15px; color: #4ec9b0; margin-top: 4px;">{}</div>
|
||||
</div>"#,
|
||||
html_escape(field_name),
|
||||
html_escape(&value_str)
|
||||
));
|
||||
}
|
||||
|
||||
html.push_str("</div></div>");
|
||||
html
|
||||
} else {
|
||||
String::from("<p style='color: #808080;'>No fields submitted</p>")
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Form Submitted Successfully</title>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {{
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
margin: 0;
|
||||
padding: 40px 20px;
|
||||
}}
|
||||
.container {{
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: #252526;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}}
|
||||
h1 {{
|
||||
color: #4ec9b0;
|
||||
margin: 0 0 10px 0;
|
||||
}}
|
||||
.header {{
|
||||
border-bottom: 2px solid #007acc;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}}
|
||||
.stat-row {{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
.actions {{
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #3e3e42;
|
||||
}}
|
||||
.btn {{
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
margin: 5px;
|
||||
background: #007acc;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}}
|
||||
.btn:hover {{
|
||||
background: #005a9e;
|
||||
}}
|
||||
.auto-close {{
|
||||
color: #808080;
|
||||
font-size: 12px;
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
}}
|
||||
</style>
|
||||
<script>
|
||||
let countdown = 30;
|
||||
function updateCountdown() {{
|
||||
document.getElementById('countdown').textContent = countdown;
|
||||
countdown--;
|
||||
if (countdown < 0) {{
|
||||
window.close();
|
||||
}}
|
||||
}}
|
||||
setInterval(updateCountdown, 1000);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>✅ Form Submitted Successfully!</h1>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<span>📊 <strong>Fields Submitted:</strong></span>
|
||||
<span>{} fields</span>
|
||||
</div>
|
||||
|
||||
{}
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn" onclick="window.location.reload()">🔄 Submit Another</button>
|
||||
<button class="btn" onclick="window.close()">❌ Close</button>
|
||||
</div>
|
||||
|
||||
<div class="auto-close">
|
||||
⏱️ This window will auto-close in <span id="countdown">30</span> seconds...
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#,
|
||||
field_count, fields_html
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
async fn submit_complete_form_handler(
|
||||
State(state): State<Arc<WebFormState>>,
|
||||
@ -902,17 +1148,16 @@ async fn submit_complete_form_handler(
|
||||
.body(Body::from(html))
|
||||
.unwrap()
|
||||
} else {
|
||||
// Normal mode: return JSON success
|
||||
// Normal mode: return HTML success page
|
||||
use axum::body::Body;
|
||||
use axum::response::Response;
|
||||
|
||||
let html = render_success_html(&all_results);
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("HX-Trigger", "formComplete")
|
||||
.body(Body::from(
|
||||
serde_json::to_string(&json!({"success": true})).unwrap(),
|
||||
))
|
||||
.header("Content-Type", "text/html; charset=utf-8")
|
||||
.body(Body::from(html))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
@ -1348,17 +1593,25 @@ fn render_field_for_complete_form(
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
let options_from_attr = if let Some(ref source_field) = field.options_from {
|
||||
format!(" data-options-from=\"{}\"", html_escape(source_field))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
(
|
||||
format!(
|
||||
r#"<div class="field" style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 5px; font-weight: bold;">{}</label>
|
||||
<select name="{}" style="width: 100%; padding: 8px; background: #1e1e1e; color: #d4d4d4; border: 1px solid #3e3e42; box-sizing: border-box;" {}>
|
||||
<select name="{}" data-fieldname="{}" {} style="width: 100%; padding: 8px; background: #1e1e1e; color: #d4d4d4; border: 1px solid #3e3e42; box-sizing: border-box;" {}>
|
||||
<option value="">-- Select --</option>
|
||||
{}
|
||||
</select>
|
||||
</div>"#,
|
||||
html_escape(&field.prompt),
|
||||
html_escape(&field.name),
|
||||
html_escape(&field.name),
|
||||
options_from_attr,
|
||||
if field.required.unwrap_or(false) {
|
||||
"required"
|
||||
} else {
|
||||
@ -1889,7 +2142,7 @@ fn render_repeating_group_field(
|
||||
.elements
|
||||
.iter()
|
||||
.filter_map(|element| match element {
|
||||
crate::form_parser::FormElement::Field(f) => Some(f),
|
||||
crate::form_parser::FormElement::Field(f) => Some(f.as_ref()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@ -2680,10 +2933,16 @@ fn render_add_item_section(
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let options_from_attr = if let Some(ref source_field) = field.options_from {
|
||||
format!(" data-options-from=\"{}\"", html_escape(source_field))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
html.push_str(&format!(
|
||||
r#"<select class="field-input" data-fieldname="{}"{} style="width: 100%; padding: 8px; background: #252526; color: #d4d4d4; border: 1px solid #3e3e42; border-radius: 3px;">"#,
|
||||
r#"<select class="field-input" data-fieldname="{}"{}{} style="width: 100%; padding: 8px; background: #252526; color: #d4d4d4; border: 1px solid #3e3e42; border-radius: 3px;">"#,
|
||||
field.name,
|
||||
required_attr
|
||||
required_attr,
|
||||
options_from_attr
|
||||
));
|
||||
for opt in &field.options {
|
||||
html.push_str(&format!(
|
||||
|
||||
@ -188,6 +188,7 @@ mod tests {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: Vec::new(),
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
//! Provides logic for evaluating `when` conditions on form elements.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use super::types::FormDefinition;
|
||||
|
||||
@ -12,11 +13,12 @@ use super::types::FormDefinition;
|
||||
/// - "enable_prometheus == true" → Some("enable_prometheus")
|
||||
/// - "provider == lxd" → Some("provider")
|
||||
/// - "grafana_port >= 3000" → Some("grafana_port")
|
||||
/// - "rust in detected_languages" → Some("detected_languages")
|
||||
pub(super) fn extract_field_from_condition(condition: &str) -> Option<String> {
|
||||
let condition = condition.trim();
|
||||
|
||||
// String operators: contains, startswith, endswith
|
||||
let string_operators = ["contains", "startswith", "endswith"];
|
||||
// String operators: contains, startswith, endswith, in
|
||||
let string_operators = ["contains", "startswith", "endswith", "in"];
|
||||
for op_str in &string_operators {
|
||||
if let Some(pos) = condition.find(op_str) {
|
||||
let before_ok = pos == 0
|
||||
@ -33,7 +35,12 @@ pub(super) fn extract_field_from_condition(condition: &str) -> Option<String> {
|
||||
.is_alphanumeric();
|
||||
|
||||
if before_ok && after_ok {
|
||||
let field_name = condition[..pos].trim();
|
||||
// For "in" operator, the field is on the right side
|
||||
let field_name = if *op_str == "in" {
|
||||
condition[pos + op_str.len()..].trim()
|
||||
} else {
|
||||
condition[..pos].trim()
|
||||
};
|
||||
return Some(field_name.to_string());
|
||||
}
|
||||
}
|
||||
@ -113,11 +120,19 @@ pub fn should_load_fragment(
|
||||
/// - "field_name != value"
|
||||
/// - "field_name contains value"
|
||||
/// - "field_name startswith value"
|
||||
/// - "value in field_name" (array membership)
|
||||
/// - "file_exists(path)" (file existence check)
|
||||
/// - "!file_exists(path)" (negated file existence check)
|
||||
pub fn evaluate_condition(condition: &str, results: &HashMap<String, serde_json::Value>) -> bool {
|
||||
let condition = condition.trim();
|
||||
|
||||
// Check for function calls first (file_exists, etc.)
|
||||
if let Some(result) = evaluate_function_call(condition) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check string operators first (word boundaries)
|
||||
let string_operators = ["contains", "startswith", "endswith"];
|
||||
let string_operators = ["contains", "startswith", "endswith", "in"];
|
||||
for op_str in &string_operators {
|
||||
if let Some(pos) = condition.find(op_str) {
|
||||
// Make sure it's word-bounded (not part of another word)
|
||||
@ -138,19 +153,53 @@ pub fn evaluate_condition(condition: &str, results: &HashMap<String, serde_json:
|
||||
let left = condition[..pos].trim();
|
||||
let right = condition[pos + op_str.len()..].trim();
|
||||
|
||||
let field_value = results
|
||||
.get(left)
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let field_str = value_to_string(&field_value);
|
||||
let expected = parse_condition_value(right);
|
||||
let expected_str = value_to_string(&expected);
|
||||
|
||||
match *op_str {
|
||||
"contains" => return field_str.contains(&expected_str),
|
||||
"startswith" => return field_str.starts_with(&expected_str),
|
||||
"endswith" => return field_str.ends_with(&expected_str),
|
||||
_ => {}
|
||||
"in" => {
|
||||
// For "in" operator: "value in array_field"
|
||||
// left = value to search for
|
||||
// right = field name containing array
|
||||
let field_value = results
|
||||
.get(right)
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let search_value = left;
|
||||
|
||||
// Handle array membership check
|
||||
match &field_value {
|
||||
serde_json::Value::Array(arr) => {
|
||||
// Check if any array element matches the search value
|
||||
return arr.iter().any(|v| {
|
||||
let v_str = value_to_string(v);
|
||||
v_str == search_value
|
||||
});
|
||||
}
|
||||
serde_json::Value::String(s) => {
|
||||
// Handle comma-separated string as array (for multiselect defaults)
|
||||
return s
|
||||
.split(',')
|
||||
.map(|item| item.trim())
|
||||
.any(|item| item == search_value);
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// For other operators: "field_name operator value"
|
||||
let field_value = results
|
||||
.get(left)
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let field_str = value_to_string(&field_value);
|
||||
let expected = parse_condition_value(right);
|
||||
let expected_str = value_to_string(&expected);
|
||||
|
||||
match *op_str {
|
||||
"contains" => return field_str.contains(&expected_str),
|
||||
"startswith" => return field_str.starts_with(&expected_str),
|
||||
"endswith" => return field_str.ends_with(&expected_str),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -205,6 +254,44 @@ pub fn evaluate_condition(condition: &str, results: &HashMap<String, serde_json:
|
||||
true
|
||||
}
|
||||
|
||||
/// Evaluate function calls in conditions
|
||||
/// Returns Some(bool) if a function call was found and evaluated
|
||||
/// Returns None if no function call was found
|
||||
fn evaluate_function_call(condition: &str) -> Option<bool> {
|
||||
let condition = condition.trim();
|
||||
|
||||
// Handle negation prefix
|
||||
let (is_negated, clean_condition) = if let Some(stripped) = condition.strip_prefix('!') {
|
||||
(true, stripped.trim())
|
||||
} else {
|
||||
(false, condition)
|
||||
};
|
||||
|
||||
// Check for file_exists() function
|
||||
if clean_condition.starts_with("file_exists(") && clean_condition.ends_with(')') {
|
||||
let start = "file_exists(".len();
|
||||
let end = clean_condition.len() - 1;
|
||||
let path_arg = clean_condition[start..end].trim();
|
||||
|
||||
// Remove quotes if present
|
||||
let path = if (path_arg.starts_with('"') && path_arg.ends_with('"'))
|
||||
|| (path_arg.starts_with('\'') && path_arg.ends_with('\''))
|
||||
{
|
||||
&path_arg[1..path_arg.len() - 1]
|
||||
} else {
|
||||
path_arg
|
||||
};
|
||||
|
||||
// Check if file exists
|
||||
let exists = Path::new(path).exists();
|
||||
|
||||
// Apply negation if needed
|
||||
return Some(if is_negated { !exists } else { exists });
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse a value from condition right-hand side
|
||||
fn parse_condition_value(s: &str) -> serde_json::Value {
|
||||
let s = s.trim();
|
||||
@ -307,3 +394,206 @@ pub fn value_to_string(v: &serde_json::Value) -> String {
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_in_operator_with_array() {
|
||||
let mut results = HashMap::new();
|
||||
results.insert(
|
||||
"detected_languages".to_string(),
|
||||
serde_json::json!(["rust", "python", "go"]),
|
||||
);
|
||||
|
||||
// Should find rust
|
||||
assert!(evaluate_condition("rust in detected_languages", &results));
|
||||
|
||||
// Should find python
|
||||
assert!(evaluate_condition("python in detected_languages", &results));
|
||||
|
||||
// Should not find javascript
|
||||
assert!(!evaluate_condition(
|
||||
"javascript in detected_languages",
|
||||
&results
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_in_operator_with_comma_separated_string() {
|
||||
let mut results = HashMap::new();
|
||||
results.insert(
|
||||
"detected_languages".to_string(),
|
||||
serde_json::json!("rust,python,go"),
|
||||
);
|
||||
|
||||
// Should find rust (with proper trimming)
|
||||
assert!(evaluate_condition("rust in detected_languages", &results));
|
||||
|
||||
// Should find python
|
||||
assert!(evaluate_condition("python in detected_languages", &results));
|
||||
|
||||
// Should not find javascript
|
||||
assert!(!evaluate_condition(
|
||||
"javascript in detected_languages",
|
||||
&results
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_in_operator_with_spaces() {
|
||||
let mut results = HashMap::new();
|
||||
results.insert(
|
||||
"detected_languages".to_string(),
|
||||
serde_json::json!(["rust", "nushell", "nickel"]),
|
||||
);
|
||||
|
||||
// Should work with spaces around "in"
|
||||
assert!(evaluate_condition("rust in detected_languages", &results));
|
||||
assert!(evaluate_condition(
|
||||
" rust in detected_languages ",
|
||||
&results
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_in_operator_not_found() {
|
||||
let mut results = HashMap::new();
|
||||
results.insert(
|
||||
"detected_languages".to_string(),
|
||||
serde_json::json!(["rust", "python"]),
|
||||
);
|
||||
|
||||
// Should not find when value not in array
|
||||
assert!(!evaluate_condition(
|
||||
"javascript in detected_languages",
|
||||
&results
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_in_operator_with_empty_array() {
|
||||
let mut results = HashMap::new();
|
||||
results.insert("detected_languages".to_string(), serde_json::json!([]));
|
||||
|
||||
// Should not find anything in empty array
|
||||
assert!(!evaluate_condition("rust in detected_languages", &results));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_in_operator_with_missing_field() {
|
||||
let results = HashMap::new();
|
||||
|
||||
// Should not find when field doesn't exist
|
||||
assert!(!evaluate_condition("rust in detected_languages", &results));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_field_from_in_condition() {
|
||||
// For "in" operator, should extract the array field name (right side)
|
||||
assert_eq!(
|
||||
extract_field_from_condition("rust in detected_languages"),
|
||||
Some("detected_languages".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
extract_field_from_condition("python in languages"),
|
||||
Some("languages".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_existing_operators_still_work() {
|
||||
let mut results = HashMap::new();
|
||||
results.insert("enable_feature".to_string(), serde_json::json!(true));
|
||||
results.insert("name".to_string(), serde_json::json!("test"));
|
||||
|
||||
// Existing operators should still work
|
||||
assert!(evaluate_condition("enable_feature == true", &results));
|
||||
assert!(evaluate_condition("name == test", &results));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_exists_with_existing_file() {
|
||||
let results = HashMap::new();
|
||||
|
||||
// Create a temporary file
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let test_file = temp_dir.join("typedialog_test_file.txt");
|
||||
std::fs::write(&test_file, "test content").unwrap();
|
||||
|
||||
// Test with double quotes
|
||||
let condition = format!("file_exists(\"{}\")", test_file.display());
|
||||
assert!(evaluate_condition(&condition, &results));
|
||||
|
||||
// Test with single quotes
|
||||
let condition = format!("file_exists('{}')", test_file.display());
|
||||
assert!(evaluate_condition(&condition, &results));
|
||||
|
||||
// Test without quotes
|
||||
let condition = format!("file_exists({})", test_file.display());
|
||||
assert!(evaluate_condition(&condition, &results));
|
||||
|
||||
// Clean up
|
||||
std::fs::remove_file(&test_file).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_exists_with_nonexistent_file() {
|
||||
let results = HashMap::new();
|
||||
|
||||
// Use a path that definitely doesn't exist
|
||||
let nonexistent = "/tmp/typedialog_nonexistent_file_12345.txt";
|
||||
|
||||
let condition = format!("file_exists(\"{}\")", nonexistent);
|
||||
assert!(!evaluate_condition(&condition, &results));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_exists_negated() {
|
||||
let results = HashMap::new();
|
||||
|
||||
// Create a temporary file
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let test_file = temp_dir.join("typedialog_test_negation.txt");
|
||||
std::fs::write(&test_file, "test").unwrap();
|
||||
|
||||
// Negated: should return false for existing file
|
||||
let condition = format!("!file_exists(\"{}\")", test_file.display());
|
||||
assert!(!evaluate_condition(&condition, &results));
|
||||
|
||||
// Clean up
|
||||
std::fs::remove_file(&test_file).unwrap();
|
||||
|
||||
// After removal, negated should return true
|
||||
let condition = format!("!file_exists(\"{}\")", test_file.display());
|
||||
assert!(evaluate_condition(&condition, &results));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_exists_with_directory() {
|
||||
let results = HashMap::new();
|
||||
|
||||
// Test with an existing directory
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let condition = format!("file_exists(\"{}\")", temp_dir.display());
|
||||
|
||||
// Path::exists() returns true for both files and directories
|
||||
assert!(evaluate_condition(&condition, &results));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_exists_with_relative_path() {
|
||||
let results = HashMap::new();
|
||||
|
||||
// Create a file in current directory
|
||||
std::fs::write("typedialog_test_relative.txt", "test").unwrap();
|
||||
|
||||
let condition = "file_exists(\"typedialog_test_relative.txt\")";
|
||||
assert!(evaluate_condition(condition, &results));
|
||||
|
||||
// Clean up
|
||||
std::fs::remove_file("typedialog_test_relative.txt").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@ -174,6 +174,41 @@ pub fn load_and_execute_from_file(
|
||||
execute_with_base_dir(form, base_dir)
|
||||
}
|
||||
|
||||
/// Filter field options based on options_from reference
|
||||
fn filter_options_from(
|
||||
field: &FieldDefinition,
|
||||
previous_results: &HashMap<String, serde_json::Value>,
|
||||
) -> Vec<super::types::SelectOption> {
|
||||
// If no options_from specified, return all options
|
||||
let Some(ref source_field) = field.options_from else {
|
||||
return field.options.clone();
|
||||
};
|
||||
|
||||
// Get the source field's value
|
||||
let Some(source_value) = previous_results.get(source_field) else {
|
||||
// Source field not found, return all options
|
||||
return field.options.clone();
|
||||
};
|
||||
|
||||
// Extract selected values from source field (could be array or comma-separated string)
|
||||
let selected_values: Vec<String> = match source_value {
|
||||
serde_json::Value::Array(arr) => arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect(),
|
||||
serde_json::Value::String(s) => s.split(',').map(|item| item.trim().to_string()).collect(),
|
||||
_ => return field.options.clone(), // Unsupported type, return all
|
||||
};
|
||||
|
||||
// Filter options to only include those in selected_values
|
||||
field
|
||||
.options
|
||||
.iter()
|
||||
.filter(|opt| selected_values.contains(&opt.value))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Execute a single field
|
||||
fn execute_field(
|
||||
field: &FieldDefinition,
|
||||
@ -232,8 +267,18 @@ fn execute_field(
|
||||
));
|
||||
}
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let options = field
|
||||
.options
|
||||
|
||||
// Filter options based on options_from if specified
|
||||
let filtered_options = filter_options_from(field, _previous_results);
|
||||
|
||||
if filtered_options.is_empty() {
|
||||
return Err(crate::ErrorWrapper::form_parse_failed(format!(
|
||||
"No options available for field '{}'. Check options_from reference.",
|
||||
field.name
|
||||
)));
|
||||
}
|
||||
|
||||
let options = filtered_options
|
||||
.iter()
|
||||
.map(|opt| opt.as_string())
|
||||
.collect::<Vec<_>>();
|
||||
@ -336,7 +381,7 @@ fn build_element_list(
|
||||
for element in form.elements.iter() {
|
||||
match element {
|
||||
FormElement::Item(item) => {
|
||||
let mut item_clone = item.clone();
|
||||
let mut item_clone = item.as_ref().clone();
|
||||
|
||||
// Handle group type with includes
|
||||
if item.item_type == "group" {
|
||||
@ -387,15 +432,15 @@ fn build_element_list(
|
||||
// Non-group items get order from position counter (insertion order)
|
||||
item_clone.order = order_counter;
|
||||
order_counter += 1;
|
||||
element_list.push((item_clone.order, FormElement::Item(item_clone)));
|
||||
element_list.push((item_clone.order, FormElement::Item(Box::new(item_clone))));
|
||||
}
|
||||
}
|
||||
FormElement::Field(field) => {
|
||||
let mut field_clone = field.clone();
|
||||
let mut field_clone = field.as_ref().clone();
|
||||
// Assign order based on position counter (insertion order)
|
||||
field_clone.order = order_counter;
|
||||
order_counter += 1;
|
||||
element_list.push((field_clone.order, FormElement::Field(field_clone)));
|
||||
element_list.push((field_clone.order, FormElement::Field(Box::new(field_clone))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -448,7 +493,7 @@ pub fn recompute_visible_elements(
|
||||
.is_none_or(|cond| evaluate_condition(cond, results));
|
||||
|
||||
if should_show {
|
||||
visible_items.push(item);
|
||||
visible_items.push(*item);
|
||||
}
|
||||
}
|
||||
FormElement::Field(field) => {
|
||||
@ -459,7 +504,7 @@ pub fn recompute_visible_elements(
|
||||
.is_none_or(|cond| evaluate_condition(cond, results));
|
||||
|
||||
if should_show {
|
||||
visible_fields.push(field);
|
||||
visible_fields.push(*field);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -530,7 +575,7 @@ pub async fn execute_with_backend_complete(
|
||||
let items: Vec<&DisplayItem> = element_list
|
||||
.iter()
|
||||
.filter_map(|(_, e)| match e {
|
||||
FormElement::Item(item) => Some(item),
|
||||
FormElement::Item(item) => Some(item.as_ref()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@ -543,7 +588,7 @@ pub async fn execute_with_backend_complete(
|
||||
if selector_field_names.contains(&field.name) {
|
||||
return None;
|
||||
}
|
||||
Some(field)
|
||||
Some(field.as_ref())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
@ -795,7 +840,7 @@ pub async fn execute_with_backend_i18n_with_defaults(
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(item)
|
||||
Some(item.as_ref())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
@ -812,7 +857,7 @@ pub async fn execute_with_backend_i18n_with_defaults(
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(field)
|
||||
Some(field.as_ref())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
|
||||
@ -30,15 +30,15 @@ where
|
||||
/// Public enum for unified form structure
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FormElement {
|
||||
Item(DisplayItem),
|
||||
Field(FieldDefinition),
|
||||
Item(Box<DisplayItem>),
|
||||
Field(Box<FieldDefinition>),
|
||||
}
|
||||
|
||||
impl FormElement {
|
||||
/// Get as DisplayItem if this is an Item variant
|
||||
pub fn as_item(&self) -> Option<&DisplayItem> {
|
||||
match self {
|
||||
FormElement::Item(item) => Some(item),
|
||||
FormElement::Item(item) => Some(item.as_ref()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@ -46,7 +46,7 @@ impl FormElement {
|
||||
/// Get mutable reference as DisplayItem if this is an Item variant
|
||||
pub fn as_item_mut(&mut self) -> Option<&mut DisplayItem> {
|
||||
match self {
|
||||
FormElement::Item(item) => Some(item),
|
||||
FormElement::Item(item) => Some(item.as_mut()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@ -54,7 +54,7 @@ impl FormElement {
|
||||
/// Get as FieldDefinition if this is a Field variant
|
||||
pub fn as_field(&self) -> Option<&FieldDefinition> {
|
||||
match self {
|
||||
FormElement::Field(field) => Some(field),
|
||||
FormElement::Field(field) => Some(field.as_ref()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@ -62,7 +62,7 @@ impl FormElement {
|
||||
/// Get mutable reference as FieldDefinition if this is a Field variant
|
||||
pub fn as_field_mut(&mut self) -> Option<&mut FieldDefinition> {
|
||||
match self {
|
||||
FormElement::Field(field) => Some(field),
|
||||
FormElement::Field(field) => Some(field.as_mut()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@ -158,12 +158,12 @@ impl<'de> Deserialize<'de> for FormElement {
|
||||
let item: DisplayItem =
|
||||
serde_json::from_value(serde_json::Value::Object(fields_map))
|
||||
.map_err(de::Error::custom)?;
|
||||
Ok(FormElement::Item(item))
|
||||
Ok(FormElement::Item(Box::new(item)))
|
||||
} else if field_types.contains(&element_type.as_str()) {
|
||||
let field: FieldDefinition =
|
||||
serde_json::from_value(serde_json::Value::Object(fields_map))
|
||||
.map_err(de::Error::custom)?;
|
||||
Ok(FormElement::Field(field))
|
||||
Ok(FormElement::Field(Box::new(field)))
|
||||
} else {
|
||||
Err(de::Error::custom(format!(
|
||||
"Unknown element type '{}'. Item types: {}. Field types: {}",
|
||||
@ -300,14 +300,14 @@ impl FormDefinition {
|
||||
for mut item in self.items.drain(..) {
|
||||
// Assign order based on position to preserve insertion order
|
||||
item.order = element_list.len();
|
||||
element_list.push(FormElement::Item(item));
|
||||
element_list.push(FormElement::Item(Box::new(item)));
|
||||
}
|
||||
|
||||
// Add fields, preserving insertion order after items
|
||||
for mut field in self.fields.drain(..) {
|
||||
// Assign order based on position to preserve insertion order
|
||||
field.order = element_list.len();
|
||||
element_list.push(FormElement::Field(field));
|
||||
element_list.push(FormElement::Field(Box::new(field)));
|
||||
}
|
||||
|
||||
// Assign to elements (already in correct insertion order)
|
||||
@ -325,10 +325,10 @@ impl FormDefinition {
|
||||
for element in self.elements.drain(..) {
|
||||
match element {
|
||||
FormElement::Field(field) => {
|
||||
self.fields.push(field);
|
||||
self.fields.push(*field);
|
||||
}
|
||||
FormElement::Item(item) => {
|
||||
self.items.push(item);
|
||||
self.items.push(*item);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -436,6 +436,11 @@ pub struct FieldDefinition {
|
||||
/// Optional options list with value/label (can contain literal text or i18n keys)
|
||||
#[serde(default)]
|
||||
pub options: Vec<SelectOption>,
|
||||
/// Optional reference to another field whose values should filter this field's options
|
||||
/// Example: options_from = "detected_languages" means only show options whose values
|
||||
/// are present in the detected_languages field's selected values
|
||||
#[serde(default)]
|
||||
pub options_from: Option<String>,
|
||||
/// Optional field requirement flag
|
||||
pub required: Option<bool>,
|
||||
/// Optional file extension (for editor)
|
||||
|
||||
@ -408,6 +408,7 @@ mod tests {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: vec![],
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
@ -462,6 +463,7 @@ mod tests {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: vec![],
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
@ -516,6 +518,7 @@ mod tests {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: vec![],
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
@ -571,6 +574,7 @@ mod tests {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: vec![],
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
@ -609,6 +613,7 @@ mod tests {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: vec![],
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
@ -659,6 +664,7 @@ mod tests {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: vec![],
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
|
||||
@ -100,13 +100,18 @@ impl ContractParser {
|
||||
) {
|
||||
// Could be string, array, object, number, true, false, null
|
||||
// Skip parsing as validator call
|
||||
eprintln!(
|
||||
"[DEBUG] Skipping literal for field '{}': '{}'",
|
||||
field_name, right_side
|
||||
tracing::debug!(
|
||||
field = %field_name,
|
||||
value = %right_side,
|
||||
"Skipping literal field (not a validator call)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
eprintln!("[DEBUG] Parsing field '{}': '{}'", field_name, right_side);
|
||||
tracing::debug!(
|
||||
field = %field_name,
|
||||
expression = %right_side,
|
||||
"Parsing field with validator"
|
||||
);
|
||||
|
||||
// Extract module.function pairs from right side
|
||||
if let Some(dot_pos) = right_side.find('.') {
|
||||
|
||||
@ -55,6 +55,15 @@ pub struct RoundtripResult {
|
||||
|
||||
/// Validation result (if enabled)
|
||||
pub validation_passed: Option<bool>,
|
||||
|
||||
/// Original input Nickel code (for diff)
|
||||
pub input_nickel: String,
|
||||
|
||||
/// Path to output file
|
||||
pub output_path: PathBuf,
|
||||
|
||||
/// Initial values loaded from input (for change detection)
|
||||
pub initial_values: std::collections::HashMap<String, Value>,
|
||||
}
|
||||
|
||||
impl RoundtripConfig {
|
||||
@ -118,12 +127,26 @@ impl RoundtripConfig {
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Execute form to get results
|
||||
// Step 2: Load defaults from input .ncl (if fields have nickel_path)
|
||||
let initial_values =
|
||||
Self::load_defaults_from_input(&self.input_ncl, &self.form_path, self.verbose)?;
|
||||
|
||||
if self.verbose && !initial_values.is_empty() {
|
||||
eprintln!(
|
||||
"[roundtrip] Loaded {} default values from input",
|
||||
initial_values.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Execute form to get results (with defaults pre-populated)
|
||||
if self.verbose {
|
||||
eprintln!("[roundtrip] Executing form: {}", self.form_path.display());
|
||||
}
|
||||
|
||||
let form_results = Self::execute_form_with_backend(&self.form_path, backend).await?;
|
||||
let initial_values_backup = initial_values.clone();
|
||||
let form_results =
|
||||
Self::execute_form_with_backend_and_defaults(&self.form_path, backend, initial_values)
|
||||
.await?;
|
||||
|
||||
if self.verbose {
|
||||
eprintln!(
|
||||
@ -197,8 +220,11 @@ impl RoundtripConfig {
|
||||
Ok(RoundtripResult {
|
||||
input_contracts,
|
||||
form_results,
|
||||
output_nickel,
|
||||
output_nickel: output_nickel.clone(),
|
||||
validation_passed,
|
||||
input_nickel: input_source,
|
||||
output_path: self.output_ncl,
|
||||
initial_values: initial_values_backup,
|
||||
})
|
||||
}
|
||||
|
||||
@ -230,12 +256,24 @@ impl RoundtripConfig {
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Execute form to get results
|
||||
// Step 2: Load defaults from input .ncl (if fields have nickel_path)
|
||||
let initial_values =
|
||||
Self::load_defaults_from_input(&self.input_ncl, &self.form_path, self.verbose)?;
|
||||
|
||||
if self.verbose && !initial_values.is_empty() {
|
||||
eprintln!(
|
||||
"[roundtrip] Loaded {} default values from input",
|
||||
initial_values.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Execute form to get results (with defaults pre-populated)
|
||||
if self.verbose {
|
||||
eprintln!("[roundtrip] Executing form: {}", self.form_path.display());
|
||||
}
|
||||
|
||||
let form_results = Self::execute_form(&self.form_path)?;
|
||||
let initial_values_backup = initial_values.clone();
|
||||
let form_results = Self::execute_form_with_defaults(&self.form_path, initial_values)?;
|
||||
|
||||
if self.verbose {
|
||||
eprintln!(
|
||||
@ -309,15 +347,19 @@ impl RoundtripConfig {
|
||||
Ok(RoundtripResult {
|
||||
input_contracts,
|
||||
form_results,
|
||||
output_nickel,
|
||||
output_nickel: output_nickel.clone(),
|
||||
validation_passed,
|
||||
input_nickel: input_source,
|
||||
output_path: self.output_ncl,
|
||||
initial_values: initial_values_backup,
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a form with a specific backend and return results
|
||||
async fn execute_form_with_backend(
|
||||
/// Execute a form with a specific backend and return results (with defaults)
|
||||
async fn execute_form_with_backend_and_defaults(
|
||||
form_path: &Path,
|
||||
backend: &mut dyn FormBackend,
|
||||
initial_values: HashMap<String, Value>,
|
||||
) -> Result<HashMap<String, Value>> {
|
||||
// Read form definition
|
||||
let form_content = fs::read_to_string(form_path).map_err(|e| {
|
||||
@ -330,23 +372,418 @@ impl RoundtripConfig {
|
||||
// Migrate to unified elements format if needed
|
||||
form.migrate_to_elements();
|
||||
|
||||
// NOTE: We don't apply defaults here because execute_with_backend_two_phase_with_defaults
|
||||
// will call build_element_list which reloads fragments from disk, losing any modifications.
|
||||
// Instead, we pass initial_values to execute_with_backend_two_phase_with_defaults
|
||||
// which will apply them after fragment expansion.
|
||||
|
||||
// Extract base directory for resolving relative paths (includes, fragments)
|
||||
let base_dir = form_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
// Execute form using provided backend (TUI, Web, or CLI)
|
||||
form_parser::execute_with_backend_two_phase(form, backend, None, base_dir).await
|
||||
// Execute form using provided backend (TUI, Web, or CLI) with defaults
|
||||
form_parser::execute_with_backend_two_phase_with_defaults(
|
||||
form,
|
||||
backend,
|
||||
None,
|
||||
base_dir,
|
||||
Some(initial_values),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Execute a form and return results (CLI backend only)
|
||||
fn execute_form(form_path: &Path) -> Result<HashMap<String, Value>> {
|
||||
// Load form definition from file (resolves constraint interpolations + includes)
|
||||
let form = form_parser::load_from_file(form_path)?;
|
||||
/// Execute a form and return results (CLI backend only, with defaults)
|
||||
fn execute_form_with_defaults(
|
||||
form_path: &Path,
|
||||
initial_values: HashMap<String, Value>,
|
||||
) -> Result<HashMap<String, Value>> {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// Read form definition
|
||||
let form_content = fs::read_to_string(form_path).map_err(|e| {
|
||||
crate::error::ErrorWrapper::new(format!("Failed to read form file: {}", e))
|
||||
})?;
|
||||
|
||||
// Parse TOML form definition
|
||||
let mut form = form_parser::parse_toml(&form_content)?;
|
||||
|
||||
// Migrate to unified elements format
|
||||
form.migrate_to_elements();
|
||||
|
||||
// Extract base directory for resolving relative paths (includes, fragments)
|
||||
let base_dir = form_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
// Execute form using CLI backend (interactive prompts)
|
||||
form_parser::execute_with_base_dir(form, base_dir)
|
||||
// CRITICAL FIX: Expand fragments BEFORE applying defaults
|
||||
// This ensures defaults are applied to real fields, not to groups with includes
|
||||
let mut expanded_form = form_parser::expand_includes(form, base_dir)?;
|
||||
|
||||
// Now apply initial values as defaults to the EXPANDED fields
|
||||
for element in &mut expanded_form.elements {
|
||||
if let form_parser::FormElement::Field(field) = element {
|
||||
if let Some(value) = initial_values.get(&field.name) {
|
||||
// Set as default if field doesn't already have one
|
||||
if field.default.is_none() {
|
||||
field.default = Some(form_parser::value_to_string(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute expanded form directly (without re-expanding includes)
|
||||
// This is a simplified version of execute_with_base_dir that doesn't call expand_includes again
|
||||
let mut results = HashMap::new();
|
||||
|
||||
// Print form header
|
||||
if let Some(desc) = &expanded_form.description {
|
||||
println!("\n{}\n{}\n", expanded_form.name, desc);
|
||||
} else {
|
||||
println!("\n{}\n", expanded_form.name);
|
||||
}
|
||||
|
||||
// Build ordered element map
|
||||
let mut element_map: BTreeMap<usize, form_parser::FormElement> = BTreeMap::new();
|
||||
let mut order_counter = 0;
|
||||
|
||||
for element in expanded_form.elements {
|
||||
let order = match &element {
|
||||
form_parser::FormElement::Item(item) => {
|
||||
if item.order == 0 {
|
||||
order_counter += 1;
|
||||
order_counter - 1
|
||||
} else {
|
||||
item.order
|
||||
}
|
||||
}
|
||||
form_parser::FormElement::Field(field) => {
|
||||
if field.order == 0 {
|
||||
order_counter += 1;
|
||||
order_counter - 1
|
||||
} else {
|
||||
field.order
|
||||
}
|
||||
}
|
||||
};
|
||||
element_map.insert(order, element);
|
||||
}
|
||||
|
||||
// Process elements in order (using CLI prompts)
|
||||
for (_, element) in element_map.iter() {
|
||||
match element {
|
||||
form_parser::FormElement::Item(item) => {
|
||||
form_parser::render_display_item(item, &results);
|
||||
}
|
||||
form_parser::FormElement::Field(field) => {
|
||||
// Check if field should be shown based on conditional
|
||||
if let Some(condition) = &field.when {
|
||||
if !form_parser::evaluate_condition(condition, &results) {
|
||||
// Field condition not met, skip it
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute field using CLI prompts (from execute_with_base_dir logic)
|
||||
let value = Self::execute_field_cli(field, &results)?;
|
||||
results.insert(field.name.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Execute a single field using CLI prompts (extracted from executor.rs)
|
||||
fn execute_field_cli(
|
||||
field: &form_parser::FieldDefinition,
|
||||
previous_results: &HashMap<String, Value>,
|
||||
) -> Result<Value> {
|
||||
use crate::prompts;
|
||||
|
||||
let is_required = field.required.unwrap_or(false);
|
||||
let required_marker = if is_required { " *" } else { " (optional)" };
|
||||
|
||||
match field.field_type {
|
||||
form_parser::FieldType::Text => {
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let result = prompts::text(
|
||||
&prompt_with_marker,
|
||||
field.default.as_deref(),
|
||||
field.placeholder.as_deref(),
|
||||
)?;
|
||||
|
||||
if is_required && result.is_empty() {
|
||||
eprintln!("⚠ This field is required. Please enter a value.");
|
||||
return Self::execute_field_cli(field, previous_results); // Retry
|
||||
}
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
|
||||
form_parser::FieldType::Confirm => {
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let default_bool =
|
||||
field
|
||||
.default
|
||||
.as_deref()
|
||||
.and_then(|s| match s.to_lowercase().as_str() {
|
||||
"true" | "yes" => Some(true),
|
||||
"false" | "no" => Some(false),
|
||||
_ => None,
|
||||
});
|
||||
let result = prompts::confirm(&prompt_with_marker, default_bool, None)?;
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
|
||||
form_parser::FieldType::Password => {
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let with_toggle = field.placeholder.as_deref() == Some("toggle");
|
||||
let result = prompts::password(&prompt_with_marker, with_toggle)?;
|
||||
|
||||
if is_required && result.is_empty() {
|
||||
eprintln!("⚠ This field is required. Please enter a value.");
|
||||
return Self::execute_field_cli(field, previous_results); // Retry
|
||||
}
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
|
||||
form_parser::FieldType::Select => {
|
||||
if field.options.is_empty() {
|
||||
return Err(crate::error::ErrorWrapper::new(
|
||||
"Select field requires 'options'".to_string(),
|
||||
));
|
||||
}
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
|
||||
// Filter options based on options_from if specified
|
||||
let filtered_options = Self::filter_options_from(field, previous_results);
|
||||
|
||||
if filtered_options.is_empty() {
|
||||
return Err(crate::error::ErrorWrapper::new(format!(
|
||||
"No options available for field '{}'. Check options_from reference.",
|
||||
field.name
|
||||
)));
|
||||
}
|
||||
|
||||
let options = filtered_options
|
||||
.iter()
|
||||
.map(|opt| opt.as_string())
|
||||
.collect::<Vec<_>>();
|
||||
let result = prompts::select(
|
||||
&prompt_with_marker,
|
||||
options,
|
||||
field.page_size,
|
||||
field.vim_mode.unwrap_or(false),
|
||||
)?;
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
|
||||
form_parser::FieldType::MultiSelect => {
|
||||
if field.options.is_empty() {
|
||||
return Err(crate::error::ErrorWrapper::new(
|
||||
"MultiSelect field requires 'options'".to_string(),
|
||||
));
|
||||
}
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let options = field
|
||||
.options
|
||||
.iter()
|
||||
.map(|opt| opt.as_string())
|
||||
.collect::<Vec<_>>();
|
||||
let results = prompts::multi_select(
|
||||
&prompt_with_marker,
|
||||
options,
|
||||
field.page_size,
|
||||
field.vim_mode.unwrap_or(false),
|
||||
)?;
|
||||
|
||||
if is_required && results.is_empty() {
|
||||
eprintln!("⚠ This field is required. Please select at least one option.");
|
||||
return Self::execute_field_cli(field, previous_results); // Retry
|
||||
}
|
||||
Ok(serde_json::json!(results))
|
||||
}
|
||||
|
||||
form_parser::FieldType::Editor => {
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let result = prompts::editor(
|
||||
&prompt_with_marker,
|
||||
field.file_extension.as_deref(),
|
||||
field.prefix_text.as_deref(),
|
||||
)?;
|
||||
|
||||
if is_required && result.is_empty() {
|
||||
eprintln!("⚠ This field is required. Please enter a value.");
|
||||
return Self::execute_field_cli(field, previous_results); // Retry
|
||||
}
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
|
||||
form_parser::FieldType::Date => {
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let week_start = field.week_start.as_deref().unwrap_or("Mon");
|
||||
let result = prompts::date(
|
||||
&prompt_with_marker,
|
||||
field.default.as_deref(),
|
||||
field.min_date.as_deref(),
|
||||
field.max_date.as_deref(),
|
||||
week_start,
|
||||
)?;
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
|
||||
form_parser::FieldType::Custom => {
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let type_name = field.custom_type.as_ref().ok_or_else(|| {
|
||||
crate::error::ErrorWrapper::new(
|
||||
"Custom field requires 'custom_type'".to_string(),
|
||||
)
|
||||
})?;
|
||||
let result =
|
||||
prompts::custom(&prompt_with_marker, type_name, field.default.as_deref())?;
|
||||
|
||||
if is_required && result.is_empty() {
|
||||
eprintln!("⚠ This field is required. Please enter a value.");
|
||||
return Self::execute_field_cli(field, previous_results); // Retry
|
||||
}
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
|
||||
form_parser::FieldType::RepeatingGroup => Err(crate::error::ErrorWrapper::new(
|
||||
"RepeatingGroup not yet implemented".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter field options based on options_from reference (extracted from executor.rs)
|
||||
fn filter_options_from(
|
||||
field: &form_parser::FieldDefinition,
|
||||
previous_results: &HashMap<String, Value>,
|
||||
) -> Vec<form_parser::SelectOption> {
|
||||
// If no options_from specified, return all options
|
||||
let Some(ref source_field) = field.options_from else {
|
||||
return field.options.clone();
|
||||
};
|
||||
|
||||
// Get the source field's value
|
||||
let Some(source_value) = previous_results.get(source_field) else {
|
||||
// Source field not found, return all options
|
||||
return field.options.clone();
|
||||
};
|
||||
|
||||
// Extract selected values from source field (could be array or comma-separated string)
|
||||
let selected_values: Vec<String> = match source_value {
|
||||
Value::Array(arr) => arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect(),
|
||||
Value::String(s) => s.split(',').map(|item| item.trim().to_string()).collect(),
|
||||
_ => return field.options.clone(), // Unsupported type, return all
|
||||
};
|
||||
|
||||
// Filter options to only include those in selected_values
|
||||
field
|
||||
.options
|
||||
.iter()
|
||||
.filter(|opt| selected_values.contains(&opt.value))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Load defaults from input Nickel file using form field nickel_path
|
||||
fn load_defaults_from_input(
|
||||
input_path: &Path,
|
||||
form_path: &Path,
|
||||
verbose: bool,
|
||||
) -> Result<HashMap<String, Value>> {
|
||||
// Export input .ncl to JSON
|
||||
let json_value = match NickelCli::export(input_path) {
|
||||
Ok(val) => val,
|
||||
Err(_) => {
|
||||
// If export fails (e.g., file is empty or has syntax errors),
|
||||
// return empty defaults rather than failing the whole roundtrip
|
||||
if verbose {
|
||||
eprintln!(
|
||||
"[roundtrip] Warning: Could not export input .ncl, proceeding without defaults"
|
||||
);
|
||||
}
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
};
|
||||
|
||||
// Load form to get field definitions with nickel_path
|
||||
let form_content = fs::read_to_string(form_path).map_err(|e| {
|
||||
crate::error::ErrorWrapper::new(format!("Failed to read form file: {}", e))
|
||||
})?;
|
||||
|
||||
let mut form = form_parser::parse_toml(&form_content)?;
|
||||
form.migrate_to_elements();
|
||||
|
||||
// Extract base directory for resolving fragment includes
|
||||
let base_dir = form_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
// Expand fragments to get ALL fields (including those in conditional groups)
|
||||
// Uses expand_includes to process group elements with includes
|
||||
let expanded_form = form_parser::expand_includes(form, base_dir)?;
|
||||
|
||||
// Extract field definitions that have nickel_path
|
||||
let fields_with_paths: Vec<_> = expanded_form
|
||||
.elements
|
||||
.iter()
|
||||
.filter_map(|elem| {
|
||||
if let form_parser::FormElement::Field(field) = elem {
|
||||
if field.nickel_path.is_some() {
|
||||
Some(field.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if fields_with_paths.is_empty() {
|
||||
// No fields with nickel_path, return empty
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
// Extract values using nickel_path
|
||||
let mut defaults = HashMap::new();
|
||||
|
||||
if let Value::Object(obj) = json_value {
|
||||
for field in &fields_with_paths {
|
||||
if let Some(nickel_path) = &field.nickel_path {
|
||||
if let Some(value) =
|
||||
Self::extract_value_by_path(&Value::Object(obj.clone()), nickel_path)
|
||||
{
|
||||
defaults.insert(field.name.clone(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if verbose && !defaults.is_empty() {
|
||||
eprintln!(
|
||||
"[roundtrip] Extracted {} default values from {} fields with nickel_path",
|
||||
defaults.len(),
|
||||
fields_with_paths.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(defaults)
|
||||
}
|
||||
|
||||
/// Extract a value from nested JSON using a path
|
||||
fn extract_value_by_path(json: &Value, path: &[String]) -> Option<Value> {
|
||||
let mut current = json;
|
||||
|
||||
for key in path {
|
||||
match current {
|
||||
Value::Object(map) => {
|
||||
current = map.get(key)?;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
|
||||
Some(current.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@ -367,4 +804,39 @@ mod tests {
|
||||
assert_eq!(config.output_ncl, PathBuf::from("output.ncl"));
|
||||
assert!(config.validate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_value_by_path() {
|
||||
use serde_json::json;
|
||||
|
||||
let json = json!({
|
||||
"ci": {
|
||||
"project": {
|
||||
"name": "test-project"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let path = vec!["ci".to_string(), "project".to_string(), "name".to_string()];
|
||||
let result = RoundtripConfig::extract_value_by_path(&json, &path);
|
||||
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap(), json!("test-project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_value_by_path_missing() {
|
||||
use serde_json::json;
|
||||
|
||||
let json = json!({
|
||||
"ci": {
|
||||
"project": {}
|
||||
}
|
||||
});
|
||||
|
||||
let path = vec!["ci".to_string(), "project".to_string(), "name".to_string()];
|
||||
let result = RoundtripConfig::extract_value_by_path(&json, &path);
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,28 +96,25 @@ impl RoundtripSummary {
|
||||
|
||||
output.push('\n');
|
||||
output.push_str("╔════════════════════════════════════════════════════════════╗\n");
|
||||
output.push_str("║ ✅ Configuration Saved Successfully! ║\n");
|
||||
output.push_str("╠════════════════════════════════════════════════════════════╣\n");
|
||||
output.push_str(&format!(
|
||||
"║ 📄 File: {:<48} ║\n",
|
||||
truncate(&self.output_path, 48)
|
||||
));
|
||||
output.push_str(" ✅ Configuration Saved Successfully!\n");
|
||||
output.push_str("════════════════════════════════════════════════════════════\n");
|
||||
output.push_str(&format!(" 📄 File: {}\n", self.output_path));
|
||||
|
||||
if let Some(passed) = self.validation_passed {
|
||||
let status = if passed { "✓ PASSED" } else { "✗ FAILED" };
|
||||
output.push_str(&format!("║ ✓ Validation: {:<43} ║\n", status));
|
||||
output.push_str(&format!(" ✓ Validation: {}\n", status));
|
||||
}
|
||||
|
||||
output.push_str(&format!(
|
||||
"║ 📊 Fields: {}/{} changed, {} unchanged{:<18} ║\n",
|
||||
self.changed_fields, self.total_fields, self.unchanged_fields, ""
|
||||
" 📊 Fields: {}/{} changed, {} unchanged\n",
|
||||
self.changed_fields, self.total_fields, self.unchanged_fields
|
||||
));
|
||||
|
||||
output.push_str("╠════════════════════════════════════════════════════════════╣\n");
|
||||
output.push_str("════════════════════════════════════════════════════════════\n");
|
||||
|
||||
// Show changes
|
||||
if self.changed_fields > 0 {
|
||||
output.push_str("║ 📋 What Changed: ║\n");
|
||||
output.push_str(" 📋 What Changed:\n");
|
||||
|
||||
let mut shown = 0;
|
||||
for change in &self.changes {
|
||||
@ -128,30 +125,29 @@ impl RoundtripSummary {
|
||||
if !verbose && shown >= 10 {
|
||||
let remaining = self.changed_fields - shown;
|
||||
output.push_str(&format!(
|
||||
"║ ... and {} more changes (use --verbose to see all){:<4} ║\n",
|
||||
remaining, ""
|
||||
" ... and {} more changes (use --verbose to see all)\n",
|
||||
remaining
|
||||
));
|
||||
break;
|
||||
}
|
||||
|
||||
let line = format!(
|
||||
" ├─ {}: {} → {}",
|
||||
output.push_str(&format!(
|
||||
" ├─ {}: {} → {}\n",
|
||||
change.field_name,
|
||||
truncate(&change.old_value, 15),
|
||||
truncate(&change.new_value, 15)
|
||||
);
|
||||
output.push_str(&format!("║ {:<58} ║\n", truncate(&line, 58)));
|
||||
truncate(&change.old_value, 20),
|
||||
truncate(&change.new_value, 20)
|
||||
));
|
||||
shown += 1;
|
||||
}
|
||||
} else {
|
||||
output.push_str("║ 📋 No changes made ║\n");
|
||||
output.push_str(" 📋 No changes made\n");
|
||||
}
|
||||
|
||||
output.push_str("╠════════════════════════════════════════════════════════════╣\n");
|
||||
output.push_str("║ 💡 Next Steps: ║\n");
|
||||
output.push_str("║ • Review: cat config.ncl ║\n");
|
||||
output.push_str("║ • Apply CI tools: ./setup-ci.sh ║\n");
|
||||
output.push_str("║ • Re-configure: ./ci-configure.sh ║\n");
|
||||
output.push_str("════════════════════════════════════════════════════════════\n");
|
||||
output.push_str(" 💡 Next Steps:\n");
|
||||
output.push_str(" • Review: cat config.ncl\n");
|
||||
output.push_str(" • Apply CI tools: ./setup-ci.sh\n");
|
||||
output.push_str(" • Re-configure: ./ci-configure.sh\n");
|
||||
output.push_str("╚════════════════════════════════════════════════════════════╝\n");
|
||||
output.push('\n');
|
||||
|
||||
|
||||
@ -316,6 +316,7 @@ impl TomlGenerator {
|
||||
default,
|
||||
placeholder: None,
|
||||
options,
|
||||
options_from: None,
|
||||
required,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
@ -474,6 +475,7 @@ impl TomlGenerator {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: Vec::new(),
|
||||
options_from: None,
|
||||
required: Some(!field.optional),
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
|
||||
@ -24,6 +24,7 @@ mod encryption_tests {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: vec![],
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
@ -128,6 +129,7 @@ mod encryption_tests {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: vec![],
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
@ -185,6 +187,7 @@ mod encryption_tests {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: vec![],
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
@ -241,6 +244,7 @@ mod encryption_tests {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: vec![],
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
@ -335,6 +339,7 @@ mod age_roundtrip_tests {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: vec![],
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
|
||||
@ -1200,6 +1200,7 @@ fn test_encryption_roundtrip_with_redaction() {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: vec![],
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
@ -1238,6 +1239,7 @@ fn test_encryption_roundtrip_with_redaction() {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: vec![],
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
@ -1276,6 +1278,7 @@ fn test_encryption_roundtrip_with_redaction() {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: vec![],
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
@ -1352,6 +1355,7 @@ fn test_encryption_auto_detection_from_field_type() {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: vec![],
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
@ -1416,6 +1420,7 @@ fn test_sensitive_field_explicit_override() {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: vec![],
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
@ -1486,6 +1491,7 @@ fn test_mixed_sensitive_and_non_sensitive_fields() {
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: vec![],
|
||||
options_from: None,
|
||||
required: None,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
|
||||
@ -24,6 +24,7 @@ tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
unic-langid = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@ -181,23 +181,33 @@ pub async fn nickel_roundtrip(
|
||||
eprintln!("[roundtrip] Generated {} bytes", result.output_nickel.len());
|
||||
}
|
||||
|
||||
// Print summary
|
||||
println!("✓ Roundtrip completed successfully (TUI backend)");
|
||||
println!(" Input fields: {}", result.form_results.len());
|
||||
println!(
|
||||
" Imports preserved: {}",
|
||||
result.input_contracts.imports.len()
|
||||
);
|
||||
println!(
|
||||
" Contracts preserved: {}",
|
||||
result.input_contracts.field_contracts.len()
|
||||
// Create and print terminal summary
|
||||
use typedialog_core::nickel::summary::RoundtripSummary;
|
||||
|
||||
let summary = RoundtripSummary::from_values(
|
||||
&result.initial_values,
|
||||
&result.form_results,
|
||||
result.validation_passed,
|
||||
result.output_path.display().to_string(),
|
||||
);
|
||||
|
||||
// Print terminal summary
|
||||
print!("{}", summary.render_terminal(verbose));
|
||||
|
||||
println!(
|
||||
"\n✅ Configuration saved to: {}\n",
|
||||
result.output_path.display()
|
||||
);
|
||||
println!("Next steps:");
|
||||
println!(
|
||||
" - Review the configuration: cat {}",
|
||||
result.output_path.display()
|
||||
);
|
||||
println!(" - Apply CI tools: (run your CI setup command)");
|
||||
println!(" - Re-run this script anytime to update your configuration\n");
|
||||
|
||||
if let Some(passed) = result.validation_passed {
|
||||
if passed {
|
||||
println!(" ✓ Validation: PASSED");
|
||||
} else {
|
||||
println!(" ✗ Validation: FAILED");
|
||||
if !passed {
|
||||
return Err(Error::validation_failed(
|
||||
"Nickel typecheck failed on output",
|
||||
));
|
||||
|
||||
@ -149,6 +149,14 @@ enum Commands {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Initialize tracing subscriber with env filter (respects RUST_LOG)
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
// Load configuration with CLI override
|
||||
|
||||
@ -24,6 +24,7 @@ tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
unic-langid = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@ -211,6 +211,14 @@ fn extract_nickel_defaults(
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Initialize tracing subscriber with env filter (respects RUST_LOG)
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
// Load configuration with CLI override
|
||||
@ -312,7 +320,7 @@ fn load_nickel_defaults(
|
||||
let form_fields: Vec<form_parser::FieldDefinition> = form_elements
|
||||
.iter()
|
||||
.filter_map(|elem| match elem {
|
||||
form_parser::FormElement::Field(f) => Some(f.clone()),
|
||||
form_parser::FormElement::Field(f) => Some(f.as_ref().clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@ -393,7 +401,7 @@ async fn execute_form(
|
||||
.elements
|
||||
.iter()
|
||||
.filter_map(|elem| match elem {
|
||||
form_parser::FormElement::Field(f) => Some(f.clone()),
|
||||
form_parser::FormElement::Field(f) => Some(f.as_ref().clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@ -427,7 +435,7 @@ async fn execute_form(
|
||||
.elements
|
||||
.iter()
|
||||
.filter_map(|elem| match elem {
|
||||
form_parser::FormElement::Field(f) => Some(f.clone()),
|
||||
form_parser::FormElement::Field(f) => Some(f.as_ref().clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@ -589,11 +597,32 @@ async fn nickel_roundtrip_cmd(
|
||||
let port = 8080;
|
||||
let mut backend = BackendFactory::create(BackendType::Web { port })?;
|
||||
|
||||
// Set roundtrip context for web backend so it can show summary page
|
||||
if let Some(init_vals) = &initial_values {
|
||||
use typedialog_core::backends::web::RoundtripContext;
|
||||
|
||||
// Downcast to WebBackend to access state
|
||||
let web_backend = backend
|
||||
.as_any()
|
||||
.downcast_ref::<typedialog_core::backends::web::WebBackend>()
|
||||
.ok_or_else(|| Error::validation_failed("Expected WebBackend"))?;
|
||||
|
||||
if let Some(state) = web_backend.get_state() {
|
||||
let context = RoundtripContext {
|
||||
initial_values: init_vals.clone(),
|
||||
output_path: output.clone(),
|
||||
input_nickel: input_source.clone(),
|
||||
};
|
||||
state.set_roundtrip_context(context).await;
|
||||
}
|
||||
}
|
||||
|
||||
println!("Starting interactive form on http://localhost:{}", port);
|
||||
println!("Complete the form and submit to continue...\n");
|
||||
|
||||
// USE THE SAME EXECUTION PATH AS NORMAL WEB BACKEND
|
||||
// This respects display_mode and calls execute_form_complete() when display_mode = "complete"
|
||||
let initial_values_backup = initial_values.clone();
|
||||
let form_results = form_parser::execute_with_backend_i18n_with_defaults(
|
||||
form,
|
||||
backend.as_mut(),
|
||||
@ -669,24 +698,29 @@ async fn nickel_roundtrip_cmd(
|
||||
true
|
||||
};
|
||||
|
||||
// Print summary
|
||||
println!("✓ Roundtrip completed successfully (Web backend - interactive)");
|
||||
println!(" Input fields: {}", form_results.len());
|
||||
println!(" Imports preserved: {}", input_contracts.imports.len());
|
||||
println!(
|
||||
" Contracts preserved: {}",
|
||||
input_contracts.field_contracts.len()
|
||||
// Create and print terminal summary
|
||||
use typedialog_core::nickel::summary::RoundtripSummary;
|
||||
|
||||
let summary = RoundtripSummary::from_values(
|
||||
&initial_values_backup.unwrap_or_default(),
|
||||
&form_results,
|
||||
Some(validation_passed),
|
||||
output.display().to_string(),
|
||||
);
|
||||
|
||||
if validate {
|
||||
if validation_passed {
|
||||
println!(" ✓ Validation: PASSED");
|
||||
} else {
|
||||
println!(" ✗ Validation: FAILED");
|
||||
return Err(Error::validation_failed(
|
||||
"Nickel typecheck failed on output",
|
||||
));
|
||||
}
|
||||
// Print terminal summary
|
||||
print!("{}", summary.render_terminal(false));
|
||||
|
||||
println!("\n✅ Configuration saved to: {}\n", output.display());
|
||||
println!("Next steps:");
|
||||
println!(" - Review the configuration: cat {}", output.display());
|
||||
println!(" - Apply CI tools: (run your CI setup command)");
|
||||
println!(" - Re-run this script anytime to update: .typedialog/ci/ci-configure.sh\n");
|
||||
|
||||
if !validation_passed {
|
||||
return Err(Error::validation_failed(
|
||||
"Nickel typecheck failed on output",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@ -24,6 +24,7 @@ tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
unic-langid = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@ -175,22 +175,33 @@ pub fn nickel_roundtrip(
|
||||
eprintln!("[roundtrip] Generated {} bytes", result.output_nickel.len());
|
||||
}
|
||||
|
||||
println!("✓ Roundtrip completed successfully");
|
||||
println!(" Input fields: {}", result.form_results.len());
|
||||
println!(
|
||||
" Imports preserved: {}",
|
||||
result.input_contracts.imports.len()
|
||||
);
|
||||
println!(
|
||||
" Contracts preserved: {}",
|
||||
result.input_contracts.field_contracts.len()
|
||||
// Create and print terminal summary
|
||||
use typedialog_core::nickel::summary::RoundtripSummary;
|
||||
|
||||
let summary = RoundtripSummary::from_values(
|
||||
&result.initial_values,
|
||||
&result.form_results,
|
||||
result.validation_passed,
|
||||
result.output_path.display().to_string(),
|
||||
);
|
||||
|
||||
// Print terminal summary
|
||||
print!("{}", summary.render_terminal(verbose));
|
||||
|
||||
println!(
|
||||
"\n✅ Configuration saved to: {}\n",
|
||||
result.output_path.display()
|
||||
);
|
||||
println!("Next steps:");
|
||||
println!(
|
||||
" - Review the configuration: cat {}",
|
||||
result.output_path.display()
|
||||
);
|
||||
println!(" - Apply CI tools: (run your CI setup command)");
|
||||
println!(" - Re-run this script anytime to update your configuration\n");
|
||||
|
||||
if let Some(passed) = result.validation_passed {
|
||||
if passed {
|
||||
println!(" ✓ Validation: PASSED");
|
||||
} else {
|
||||
println!(" ✗ Validation: FAILED");
|
||||
if !passed {
|
||||
return Err(Error::validation_failed(
|
||||
"Nickel typecheck failed on output",
|
||||
));
|
||||
|
||||
@ -300,6 +300,14 @@ enum Commands {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Initialize tracing subscriber with env filter (respects RUST_LOG)
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Load configuration with CLI override
|
||||
|
||||
@ -453,12 +453,135 @@ when = "database_driver == mysql"
|
||||
required = true
|
||||
```
|
||||
|
||||
**Supported operators**:
|
||||
### Comparison Operators
|
||||
|
||||
- `==`: Equality
|
||||
- `!=`: Inequality
|
||||
- Parentheses for grouping (future)
|
||||
- Logical AND/OR (future)
|
||||
**Equality and inequality**:
|
||||
|
||||
- `==`: Equal to
|
||||
- `!=`: Not equal to
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "postgres_config"
|
||||
type = "text"
|
||||
when = "database_driver == postgresql"
|
||||
|
||||
[[elements]]
|
||||
name = "legacy_warning"
|
||||
type = "section"
|
||||
content = "⚠️ SQLite is for development only"
|
||||
when = "database_driver != postgresql"
|
||||
```
|
||||
|
||||
**Numeric comparisons**:
|
||||
|
||||
- `>`: Greater than
|
||||
- `<`: Less than
|
||||
- `>=`: Greater than or equal
|
||||
- `<=`: Less than or equal
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "performance_warning"
|
||||
type = "section"
|
||||
content = "⚠️ High port number may require elevated privileges"
|
||||
when = "port >= 1024"
|
||||
|
||||
[[elements]]
|
||||
name = "pool_size_warning"
|
||||
type = "section"
|
||||
when = "connection_pool > 100"
|
||||
```
|
||||
|
||||
### String Operators
|
||||
|
||||
**String matching**:
|
||||
|
||||
- `contains`: Check if field contains substring
|
||||
- `startswith`: Check if field starts with prefix
|
||||
- `endswith`: Check if field ends with suffix
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "rust_specific"
|
||||
type = "text"
|
||||
prompt = "Rust toolchain version"
|
||||
when = "language contains rust"
|
||||
|
||||
[[elements]]
|
||||
name = "protocol_warning"
|
||||
type = "section"
|
||||
when = "url startswith https"
|
||||
|
||||
[[elements]]
|
||||
name = "yaml_parser"
|
||||
type = "select"
|
||||
when = "config_file endswith .yaml"
|
||||
```
|
||||
|
||||
### Array Membership
|
||||
|
||||
**`in` operator**: Check if value exists in array field
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "rust_features"
|
||||
type = "multiselect"
|
||||
prompt = "Rust-specific features"
|
||||
when = "rust in detected_languages"
|
||||
options = [
|
||||
{ value = "clippy", label = "Clippy linting" },
|
||||
{ value = "cargo_audit", label = "Security auditing" }
|
||||
]
|
||||
|
||||
[[elements]]
|
||||
name = "python_virtualenv"
|
||||
type = "confirm"
|
||||
when = "python in languages"
|
||||
```
|
||||
|
||||
**Note**: The `in` operator works with:
|
||||
|
||||
- Array fields (JSON array values)
|
||||
- MultiSelect fields (comma-separated strings)
|
||||
|
||||
### File System Conditions
|
||||
|
||||
**`file_exists(path)`**: Check if file or directory exists
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "use_existing_config"
|
||||
type = "confirm"
|
||||
prompt = "Existing config.toml found. Use it?"
|
||||
when = "file_exists(config.toml)"
|
||||
|
||||
[[elements]]
|
||||
name = "create_new_config"
|
||||
type = "text"
|
||||
prompt = "Configuration name"
|
||||
when = "!file_exists(.env)"
|
||||
```
|
||||
|
||||
**Negation with `!`**:
|
||||
|
||||
- `!file_exists(path)`: File does NOT exist
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "docker_setup"
|
||||
type = "group"
|
||||
includes = ["fragments/docker-init.toml"]
|
||||
when = "!file_exists(Dockerfile)"
|
||||
```
|
||||
|
||||
### Future Support
|
||||
|
||||
**Planned features**:
|
||||
|
||||
- Parentheses for grouping: `(a == b) && (c == d)`
|
||||
- Logical AND: `&&`
|
||||
- Logical OR: `||`
|
||||
|
||||
---
|
||||
|
||||
|
||||
332
docs/nickel.md
332
docs/nickel.md
@ -104,15 +104,22 @@ let rendered = template_engine.render("form.j2", &context)?;
|
||||
|
||||
### roundtrip
|
||||
|
||||
Idempotent read/write for Nickel schemas.
|
||||
Idempotent read/write for Nickel schemas - complete workflow from `.ncl` → form → `.ncl`.
|
||||
|
||||
```rust
|
||||
use typedialog_core::nickel::RoundtripConfig;
|
||||
|
||||
let config = RoundtripConfig::default();
|
||||
let schema = parser.parse_file("form.ncl")?;
|
||||
let modified = transform(schema)?;
|
||||
serializer.write_file("form.ncl", &modified, &config)?;
|
||||
let mut config = RoundtripConfig::with_template(
|
||||
input_ncl, // Path to existing .ncl file
|
||||
form_toml, // Path to form definition
|
||||
output_ncl, // Path for generated .ncl
|
||||
template, // Optional .ncl.j2 template
|
||||
);
|
||||
config.validate = true;
|
||||
config.verbose = true;
|
||||
|
||||
// Execute with any backend (CLI, TUI, Web)
|
||||
let result = config.execute_with_backend(backend.as_mut()).await?;
|
||||
```
|
||||
|
||||
**Preserves:**
|
||||
@ -121,6 +128,13 @@ serializer.write_file("form.ncl", &modified, &config)?;
|
||||
- Formatting
|
||||
- Import structure
|
||||
- Custom layouts
|
||||
- Field contracts and validators
|
||||
|
||||
**Returns:**
|
||||
|
||||
- `RoundtripSummary` with diff viewer
|
||||
- Validation status
|
||||
- Change detection (what fields changed)
|
||||
|
||||
## Schema Structure
|
||||
|
||||
@ -188,6 +202,314 @@ serializer.write_file("form.ncl", &modified, &config)?;
|
||||
}
|
||||
```
|
||||
|
||||
## Roundtrip Workflow
|
||||
|
||||
The roundtrip workflow enables interactive reconfiguration of existing Nickel files through forms, preserving structure and generating human-readable diffs.
|
||||
|
||||
### Roundtrip Overview
|
||||
|
||||
**Workflow:** `config.ncl` (input) → **Form** (edit) → `config.ncl` (output)
|
||||
|
||||
1. **Load** existing `.ncl` configuration
|
||||
2. **Extract** default values using `nickel_path` mappings
|
||||
3. **Populate** form with current values
|
||||
4. **Edit** via CLI/TUI/Web interface
|
||||
5. **Generate** new `.ncl` using template (or preserve contracts)
|
||||
6. **Validate** with `nickel typecheck`
|
||||
7. **Show** summary with diff viewer
|
||||
|
||||
### The `nickel_path` Attribute
|
||||
|
||||
**Critical:** Fields MUST have `nickel_path` to participate in roundtrip.
|
||||
|
||||
Maps form field names to nested Nickel structure:
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "parallel_jobs"
|
||||
type = "text"
|
||||
prompt = "Parallel Jobs"
|
||||
default = "4"
|
||||
nickel_path = ["ci", "github_actions", "parallel_jobs"]
|
||||
```
|
||||
|
||||
Extracts from:
|
||||
|
||||
```nickel
|
||||
{
|
||||
ci = {
|
||||
github_actions = {
|
||||
parallel_jobs = 4
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- Array of strings representing path
|
||||
- Each element is a nested key
|
||||
- Top-level: `["field_name"]`
|
||||
- Nested: `["parent", "child", "field"]`
|
||||
- Arrays: Use RepeatingGroup with `nickel_path = ["array_name"]`
|
||||
|
||||
### Roundtrip with CLI Backend
|
||||
|
||||
```bash
|
||||
typedialog nickel-roundtrip \
|
||||
--input config.ncl \
|
||||
--form ci-form.toml \
|
||||
--output config.ncl \
|
||||
--ncl-template config.ncl.j2 \
|
||||
--verbose
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```text
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ ✅ Configuration Saved Successfully! ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ 📄 File: config.ncl ║
|
||||
║ ✓ Validation: ✓ PASSED ║
|
||||
║ 📊 Fields: 5/27 changed, 22 unchanged ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ 📋 What Changed: ║
|
||||
║ ├─ parallel_jobs: 4 → 8 ║
|
||||
║ ├─ timeout_minutes: 60 → 120 ║
|
||||
║ ├─ enable_clippy: true → false ║
|
||||
║ ├─ rust_version: stable → nightly ║
|
||||
║ ├─ cache_enabled: false → true ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ 💡 Next Steps: ║
|
||||
║ • Review: cat config.ncl ║
|
||||
║ • Apply CI tools: ./setup-ci.sh ║
|
||||
║ • Re-configure: ./ci-configure.sh ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
### Roundtrip with TUI Backend
|
||||
|
||||
```bash
|
||||
typedialog-tui nickel-roundtrip \
|
||||
--input config.ncl \
|
||||
--form ci-form.toml \
|
||||
--output config.ncl \
|
||||
--ncl-template config.ncl.j2
|
||||
```
|
||||
|
||||
Interactive TUI form + terminal summary (same as CLI).
|
||||
|
||||
### Roundtrip with Web Backend
|
||||
|
||||
```bash
|
||||
typedialog-web nickel-roundtrip \
|
||||
--input config.ncl \
|
||||
--form ci-form.toml \
|
||||
--output config.ncl \
|
||||
--ncl-template config.ncl.j2 \
|
||||
--verbose
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- Opens browser to `http://localhost:8080`
|
||||
- All fields pre-populated with current values
|
||||
- Real-time validation
|
||||
- **Summary page** on submit:
|
||||
- Visual diff viewer (old → new)
|
||||
- Field statistics
|
||||
- Download button for generated config
|
||||
- Auto-close after 30 seconds
|
||||
|
||||
**Terminal output:**
|
||||
|
||||
```text
|
||||
Starting interactive form on http://localhost:8080
|
||||
Complete the form and submit to continue...
|
||||
|
||||
Web UI available at http://localhost:8080
|
||||
[web] Complete form initialized with 63 default values
|
||||
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ ✅ Configuration Saved Successfully! ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
...
|
||||
```
|
||||
|
||||
### Form Requirements
|
||||
|
||||
For roundtrip to work, your form MUST:
|
||||
|
||||
1. **Include `nickel_path` on ALL fields:**
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "project_name"
|
||||
nickel_path = ["project", "name"] # ✅ Required
|
||||
```
|
||||
|
||||
2. **Use correct path syntax:**
|
||||
|
||||
```toml
|
||||
# Flat field
|
||||
nickel_path = ["enable_ci"]
|
||||
|
||||
# Nested field
|
||||
nickel_path = ["ci", "github_actions", "timeout"]
|
||||
|
||||
# Array field (RepeatingGroup)
|
||||
nickel_path = ["ci", "tools", "linters"]
|
||||
```
|
||||
|
||||
3. **Match template variables:**
|
||||
|
||||
```jinja2
|
||||
# Template: config.ncl.j2
|
||||
{
|
||||
project = {
|
||||
name = "{{ project_name }}", # ← Form field "project_name"
|
||||
},
|
||||
ci = {
|
||||
enabled = {{ enable_ci }}, # ← Form field "enable_ci"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Template Support
|
||||
|
||||
Use Tera templates (`.ncl.j2`) for complex output structures:
|
||||
|
||||
**Template:** `config.ncl.j2`
|
||||
|
||||
```jinja2
|
||||
# Generated by TypeDialog
|
||||
let imports = import "ci/lib.ncl"
|
||||
let validators = import "ci/validators.ncl"
|
||||
|
||||
{
|
||||
project = {
|
||||
name = "{{ project_name }}",
|
||||
version = "{{ project_version }}",
|
||||
},
|
||||
|
||||
ci = {
|
||||
github_actions = {
|
||||
enabled = {{ enable_github_actions }},
|
||||
parallel_jobs = {{ parallel_jobs }},
|
||||
timeout_minutes = {{ timeout_minutes }},
|
||||
|
||||
{% if enable_cache %}
|
||||
cache = {
|
||||
enabled = true,
|
||||
paths = {{ cache_paths | json }},
|
||||
},
|
||||
{% endif %}
|
||||
},
|
||||
|
||||
tools = {
|
||||
{% for tool in ci_tools %}
|
||||
{{ tool.name }} = {
|
||||
enabled = {{ tool.enabled }},
|
||||
version = "{{ tool.version }}",
|
||||
},
|
||||
{% endfor %}
|
||||
},
|
||||
}
|
||||
} | validators.CiConfig
|
||||
```
|
||||
|
||||
**Form values are injected automatically.**
|
||||
|
||||
### Complete Example
|
||||
|
||||
See `examples/08-nickel-roundtrip/` for a full CI configuration workflow:
|
||||
|
||||
```bash
|
||||
cd examples/08-nickel-roundtrip/
|
||||
|
||||
# Initial setup
|
||||
./01-generate-initial-config.sh
|
||||
|
||||
# Edit with CLI
|
||||
./02-roundtrip-cli.sh
|
||||
|
||||
# Edit with TUI
|
||||
./03-roundtrip-tui.sh
|
||||
|
||||
# Edit with Web
|
||||
./04-roundtrip-web.sh
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
Roundtrip automatically validates output:
|
||||
|
||||
```bash
|
||||
typedialog nickel-roundtrip \
|
||||
--input config.ncl \
|
||||
--form form.toml \
|
||||
--output config.ncl \
|
||||
--ncl-template template.ncl.j2
|
||||
# Runs: nickel typecheck config.ncl
|
||||
```
|
||||
|
||||
**Disable validation:**
|
||||
|
||||
```bash
|
||||
typedialog nickel-roundtrip ... --no-validate
|
||||
```
|
||||
|
||||
### Summary Output
|
||||
|
||||
All backends generate summaries showing:
|
||||
|
||||
- **Total fields:** How many fields in the form
|
||||
- **Changed fields:** Fields with different values
|
||||
- **Unchanged fields:** Fields that kept the same value
|
||||
- **Validation status:** Pass/fail from `nickel typecheck`
|
||||
- **Change list:** Detailed old → new for each change
|
||||
|
||||
**Verbose mode** shows ALL changes (not just first 10):
|
||||
|
||||
```bash
|
||||
typedialog nickel-roundtrip ... --verbose
|
||||
```
|
||||
|
||||
### Roundtrip Troubleshooting
|
||||
|
||||
**Issue: "No default values loaded"**
|
||||
|
||||
✓ Check all fields have `nickel_path`:
|
||||
|
||||
```bash
|
||||
grep -r "nickel_path" form.toml
|
||||
```
|
||||
|
||||
**Issue: "Field not found in output"**
|
||||
|
||||
✓ Verify template includes the field:
|
||||
|
||||
```bash
|
||||
grep "{{ field_name }}" template.ncl.j2
|
||||
```
|
||||
|
||||
**Issue: "Validation failed"**
|
||||
|
||||
✓ Check Nickel syntax manually:
|
||||
|
||||
```bash
|
||||
nickel typecheck config.ncl
|
||||
```
|
||||
|
||||
**Issue: "Values not showing in web form"**
|
||||
|
||||
✓ Ensure `nickel export` works on input:
|
||||
|
||||
```bash
|
||||
nickel export config.ncl
|
||||
```
|
||||
|
||||
## Building with Nickel
|
||||
|
||||
Build project with Nickel support:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Nickel Integration
|
||||
|
||||
Type-safe form schema generation using Nickel configuration language.
|
||||
Type-safe form schema generation and roundtrip workflows using Nickel configuration language.
|
||||
|
||||
## Files
|
||||
|
||||
@ -10,10 +10,12 @@ Type-safe form schema generation using Nickel configuration language.
|
||||
## About Nickel
|
||||
|
||||
Nickel is a powerful configuration language that provides:
|
||||
|
||||
- Strong typing
|
||||
- Validation rules
|
||||
- Reusable schemas
|
||||
- Inheritance and composition
|
||||
- **Roundtrip support** - Edit existing configs via forms
|
||||
|
||||
## Usage
|
||||
|
||||
@ -30,6 +32,44 @@ nickel eval nickel_schema.ncl > form_config.toml
|
||||
cargo run -p typedialog-web -- --config form_config.toml
|
||||
```
|
||||
|
||||
### Roundtrip Workflow (Edit Existing Configs)
|
||||
|
||||
**New!** Edit existing Nickel configurations through interactive forms:
|
||||
|
||||
```bash
|
||||
# CLI backend (command-line prompts)
|
||||
typedialog nickel-roundtrip \
|
||||
--input config.ncl \
|
||||
--form ci-form.toml \
|
||||
--output config.ncl \
|
||||
--ncl-template config.ncl.j2
|
||||
|
||||
# TUI backend (full-screen terminal UI)
|
||||
typedialog-tui nickel-roundtrip \
|
||||
--input config.ncl \
|
||||
--form ci-form.toml \
|
||||
--output config.ncl \
|
||||
--ncl-template config.ncl.j2
|
||||
|
||||
# Web backend (browser-based form with HTML diff)
|
||||
typedialog-web nickel-roundtrip \
|
||||
--input config.ncl \
|
||||
--form ci-form.toml \
|
||||
--output config.ncl \
|
||||
--ncl-template config.ncl.j2
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- ✓ Load existing values from `.ncl` files
|
||||
- ✓ Pre-populate form fields with current config
|
||||
- ✓ Generate new `.ncl` using templates
|
||||
- ✓ Show diff summary (what changed)
|
||||
- ✓ Automatic validation with `nickel typecheck`
|
||||
- ✓ HTML summary page (web backend only)
|
||||
|
||||
**See complete example:** `../08-nickel-roundtrip/`
|
||||
|
||||
### Example Nickel Schema
|
||||
|
||||
```nickel
|
||||
@ -57,11 +97,13 @@ cargo run -p typedialog-web -- --config form_config.toml
|
||||
|
||||
## Advanced Features
|
||||
|
||||
- Field inheritance
|
||||
- Custom validators
|
||||
- Conditional schemas
|
||||
- Template-driven generation
|
||||
- Schema composition
|
||||
- **Field inheritance** - Reuse common field definitions
|
||||
- **Custom validators** - Built-in schema validation
|
||||
- **Conditional schemas** - Dynamic form generation
|
||||
- **Template-driven generation** - Tera template support
|
||||
- **Schema composition** - Combine multiple schemas
|
||||
- **Roundtrip editing** - Edit existing configs via forms (NEW!)
|
||||
- **Diff viewer** - See what changed after editing (NEW!)
|
||||
|
||||
## Learn More
|
||||
|
||||
|
||||
25
examples/08-nickel-roundtrip/01-generate-initial-config.sh
Executable file
25
examples/08-nickel-roundtrip/01-generate-initial-config.sh
Executable file
@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "📝 Generating initial CI configuration..."
|
||||
echo ""
|
||||
|
||||
# config.ncl is already provided as the initial state
|
||||
if [ -f "config.ncl" ]; then
|
||||
echo "✅ Initial config.ncl already exists"
|
||||
echo ""
|
||||
echo "Current configuration:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
cat config.ncl
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " ./02-roundtrip-cli.sh # Edit with CLI backend"
|
||||
echo " ./03-roundtrip-tui.sh # Edit with TUI backend"
|
||||
echo " ./04-roundtrip-web.sh # Edit with Web backend"
|
||||
else
|
||||
echo "❌ config.ncl not found!"
|
||||
exit 1
|
||||
fi
|
||||
33
examples/08-nickel-roundtrip/02-roundtrip-cli.sh
Executable file
33
examples/08-nickel-roundtrip/02-roundtrip-cli.sh
Executable file
@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "🔧 Running nickel-roundtrip with CLI backend..."
|
||||
echo ""
|
||||
|
||||
# Check if config.ncl exists
|
||||
if [ ! -f "config.ncl" ]; then
|
||||
echo "❌ config.ncl not found! Run ./01-generate-initial-config.sh first"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Backup current config
|
||||
cp config.ncl "config.ncl.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
# Run roundtrip with CLI backend
|
||||
cargo run -p typedialog --release -- nickel-roundtrip \
|
||||
--input config.ncl \
|
||||
--form ci-form.toml \
|
||||
--output config.ncl \
|
||||
--ncl-template config.ncl.j2 \
|
||||
--verbose
|
||||
|
||||
echo ""
|
||||
echo "✅ Roundtrip completed!"
|
||||
echo ""
|
||||
echo "Review changes:"
|
||||
echo " cat config.ncl"
|
||||
echo ""
|
||||
echo "Run again:"
|
||||
echo " ./02-roundtrip-cli.sh"
|
||||
33
examples/08-nickel-roundtrip/03-roundtrip-tui.sh
Executable file
33
examples/08-nickel-roundtrip/03-roundtrip-tui.sh
Executable file
@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "🎨 Running nickel-roundtrip with TUI backend..."
|
||||
echo ""
|
||||
|
||||
# Check if config.ncl exists
|
||||
if [ ! -f "config.ncl" ]; then
|
||||
echo "❌ config.ncl not found! Run ./01-generate-initial-config.sh first"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Backup current config
|
||||
cp config.ncl "config.ncl.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
# Run roundtrip with TUI backend
|
||||
cargo run -p typedialog-tui --release -- nickel-roundtrip \
|
||||
--input config.ncl \
|
||||
--form ci-form.toml \
|
||||
--output config.ncl \
|
||||
--ncl-template config.ncl.j2 \
|
||||
--verbose
|
||||
|
||||
echo ""
|
||||
echo "✅ Roundtrip completed!"
|
||||
echo ""
|
||||
echo "Review changes:"
|
||||
echo " cat config.ncl"
|
||||
echo ""
|
||||
echo "Run again:"
|
||||
echo " ./03-roundtrip-tui.sh"
|
||||
49
examples/08-nickel-roundtrip/04-roundtrip-web.sh
Executable file
49
examples/08-nickel-roundtrip/04-roundtrip-web.sh
Executable file
@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "🌐 Running nickel-roundtrip with Web backend..."
|
||||
echo ""
|
||||
|
||||
# Check if config.ncl exists
|
||||
if [ ! -f "config.ncl" ]; then
|
||||
echo "❌ config.ncl not found! Run ./01-generate-initial-config.sh first"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Backup current config
|
||||
cp config.ncl "config.ncl.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
echo "Starting web server on http://localhost:8080"
|
||||
echo "Your browser should open automatically..."
|
||||
echo ""
|
||||
echo "Features:"
|
||||
echo " ✓ All fields pre-populated with current values"
|
||||
echo " ✓ Real-time validation"
|
||||
echo " ✓ HTML diff viewer on submit"
|
||||
echo " ✓ Download button for config.ncl"
|
||||
echo " ✓ Auto-close after 30 seconds"
|
||||
echo ""
|
||||
echo "Press Ctrl+C after form is submitted to exit"
|
||||
echo ""
|
||||
|
||||
# Open browser (macOS)
|
||||
sleep 2 && open "http://localhost:8080" &
|
||||
|
||||
# Run roundtrip with Web backend
|
||||
cargo run -p typedialog-web --release -- nickel-roundtrip \
|
||||
--input config.ncl \
|
||||
--form ci-form.toml \
|
||||
--output config.ncl \
|
||||
--ncl-template config.ncl.j2 \
|
||||
--verbose
|
||||
|
||||
echo ""
|
||||
echo "✅ Roundtrip completed!"
|
||||
echo ""
|
||||
echo "Review changes:"
|
||||
echo " cat config.ncl"
|
||||
echo ""
|
||||
echo "Run again:"
|
||||
echo " ./04-roundtrip-web.sh"
|
||||
260
examples/08-nickel-roundtrip/README.md
Normal file
260
examples/08-nickel-roundtrip/README.md
Normal file
@ -0,0 +1,260 @@
|
||||
# Nickel Roundtrip Example
|
||||
|
||||
Complete example demonstrating the roundtrip workflow: load existing configuration, edit via interactive form, and regenerate with diff viewer.
|
||||
|
||||
## Scenario
|
||||
|
||||
CI configuration management - edit GitHub Actions settings through a form interface while preserving the Nickel structure.
|
||||
|
||||
## Files
|
||||
|
||||
- **config.ncl** - Input/output Nickel configuration
|
||||
- **ci-form.toml** - Form definition with `nickel_path` mappings
|
||||
- **config.ncl.j2** - Tera template for generating output
|
||||
- **01-generate-initial-config.sh** - Create initial config
|
||||
- **02-roundtrip-cli.sh** - Edit via CLI backend
|
||||
- **03-roundtrip-tui.sh** - Edit via TUI backend
|
||||
- **04-roundtrip-web.sh** - Edit via Web backend
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Generate initial configuration
|
||||
./01-generate-initial-config.sh
|
||||
|
||||
# 2. Edit with your preferred backend:
|
||||
|
||||
# CLI (command-line prompts)
|
||||
./02-roundtrip-cli.sh
|
||||
|
||||
# TUI (full-screen terminal UI)
|
||||
./03-roundtrip-tui.sh
|
||||
|
||||
# Web (browser-based form)
|
||||
./04-roundtrip-web.sh
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### `nickel_path` Mapping
|
||||
|
||||
Every field in `ci-form.toml` has a `nickel_path` attribute mapping to the Nickel structure:
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "parallel_jobs"
|
||||
type = "text"
|
||||
prompt = "Parallel Jobs"
|
||||
default = "4"
|
||||
nickel_path = ["ci", "github_actions", "parallel_jobs"]
|
||||
```
|
||||
|
||||
Maps to:
|
||||
|
||||
```nickel
|
||||
{
|
||||
ci = {
|
||||
github_actions = {
|
||||
parallel_jobs = 4
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Template Rendering
|
||||
|
||||
The `config.ncl.j2` template uses form values to generate valid Nickel:
|
||||
|
||||
```jinja2
|
||||
{
|
||||
project = {
|
||||
name = "{{ project_name }}",
|
||||
description = "{{ project_description }}",
|
||||
},
|
||||
|
||||
ci = {
|
||||
github_actions = {
|
||||
enabled = {{ enable_github_actions }},
|
||||
parallel_jobs = {{ parallel_jobs }},
|
||||
timeout_minutes = {{ timeout_minutes }},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Summary with Diff
|
||||
|
||||
After editing, see what changed:
|
||||
|
||||
```text
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ ✅ Configuration Saved Successfully! ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ 📄 File: config.ncl ║
|
||||
║ ✓ Validation: ✓ PASSED ║
|
||||
║ 📊 Fields: 3/12 changed, 9 unchanged ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ 📋 What Changed: ║
|
||||
║ ├─ parallel_jobs: 4 → 8 ║
|
||||
║ ├─ timeout_minutes: 60 → 120 ║
|
||||
║ ├─ enable_cache: false → true ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ 💡 Next Steps: ║
|
||||
║ • Review: cat config.ncl ║
|
||||
║ • Apply CI tools: ./setup-ci.sh ║
|
||||
║ • Re-configure: ./ci-configure.sh ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
## Workflow Details
|
||||
|
||||
### 1. Load Defaults
|
||||
|
||||
Roundtrip reads `config.ncl` and extracts values using `nickel_path`:
|
||||
|
||||
```rust
|
||||
// Internally:
|
||||
nickel export config.ncl // → JSON
|
||||
extract_value_by_path(json, ["ci", "github_actions", "parallel_jobs"]) // → 4
|
||||
```
|
||||
|
||||
### 2. Populate Form
|
||||
|
||||
Form fields get pre-filled with current values:
|
||||
|
||||
- Text fields show current text
|
||||
- Numbers show current numbers
|
||||
- Booleans show current true/false
|
||||
- Select fields pre-select current option
|
||||
|
||||
### 3. Edit Interactively
|
||||
|
||||
User edits via chosen backend (CLI/TUI/Web).
|
||||
|
||||
### 4. Generate Output
|
||||
|
||||
Template renders with new values:
|
||||
|
||||
```jinja2
|
||||
parallel_jobs = {{ parallel_jobs }}, // User changed 4 → 8
|
||||
```
|
||||
|
||||
### 5. Validate
|
||||
|
||||
Automatic validation:
|
||||
|
||||
```bash
|
||||
nickel typecheck config.ncl
|
||||
```
|
||||
|
||||
### 6. Show Summary
|
||||
|
||||
Terminal summary (all backends) + HTML summary (web only).
|
||||
|
||||
## Backend Comparison
|
||||
|
||||
| Feature | CLI | TUI | Web |
|
||||
|---------|-----|-----|-----|
|
||||
| Terminal UI | ✓ | ✓ | ✓ (summary only) |
|
||||
| Browser UI | ✗ | ✗ | ✓ |
|
||||
| Pre-populated values | ✓ | ✓ | ✓ |
|
||||
| Real-time validation | ✗ | ✓ | ✓ |
|
||||
| HTML diff viewer | ✗ | ✗ | ✓ |
|
||||
| Download button | ✗ | ✗ | ✓ |
|
||||
|
||||
## Customization
|
||||
|
||||
### Add More Fields
|
||||
|
||||
1. **Update form:**
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "rust_version"
|
||||
type = "select"
|
||||
prompt = "Rust Version"
|
||||
options = [
|
||||
{ value = "stable", label = "Stable" },
|
||||
{ value = "nightly", label = "Nightly" }
|
||||
]
|
||||
nickel_path = ["ci", "rust", "version"]
|
||||
```
|
||||
|
||||
2. **Update template:**
|
||||
|
||||
```jinja2
|
||||
ci = {
|
||||
rust = {
|
||||
version = "{{ rust_version }}",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Update initial config:**
|
||||
|
||||
```nickel
|
||||
{
|
||||
ci = {
|
||||
rust = {
|
||||
version = "stable"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Change Template Logic
|
||||
|
||||
Add conditionals:
|
||||
|
||||
```jinja2
|
||||
{% if enable_cache %}
|
||||
cache = {
|
||||
enabled = true,
|
||||
paths = {{ cache_paths | json }},
|
||||
},
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### Disable Validation
|
||||
|
||||
```bash
|
||||
typedialog nickel-roundtrip \
|
||||
--input config.ncl \
|
||||
--form ci-form.toml \
|
||||
--output config.ncl \
|
||||
--ncl-template config.ncl.j2 \
|
||||
--no-validate # ← Skip nickel typecheck
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**No defaults loaded:**
|
||||
|
||||
```bash
|
||||
# Check nickel export works
|
||||
nickel export config.ncl
|
||||
|
||||
# Verify all fields have nickel_path
|
||||
grep "nickel_path" ci-form.toml | wc -l
|
||||
```
|
||||
|
||||
**Template errors:**
|
||||
|
||||
```bash
|
||||
# Test template separately
|
||||
echo '{"project_name": "test"}' | \
|
||||
tera --template config.ncl.j2
|
||||
```
|
||||
|
||||
**Validation fails:**
|
||||
|
||||
```bash
|
||||
# Check manually
|
||||
nickel typecheck config.ncl
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- [nickel.md](../../docs/nickel.md) - Full roundtrip documentation
|
||||
- [Nickel Language](https://nickel-lang.org) - Nickel reference
|
||||
- [Tera Templates](https://tera.netlify.app) - Template syntax
|
||||
237
examples/08-nickel-roundtrip/ci-form.toml
Normal file
237
examples/08-nickel-roundtrip/ci-form.toml
Normal file
@ -0,0 +1,237 @@
|
||||
name = "CI Configuration Editor"
|
||||
description = "Interactive form for editing CI/CD configuration"
|
||||
display_mode = "complete"
|
||||
|
||||
# =============================================================================
|
||||
# PROJECT INFORMATION
|
||||
# =============================================================================
|
||||
|
||||
[[elements]]
|
||||
name = "project_header"
|
||||
type = "section_header"
|
||||
title = "📦 Project Information"
|
||||
border_top = true
|
||||
border_bottom = true
|
||||
|
||||
[[elements]]
|
||||
name = "project_name"
|
||||
type = "text"
|
||||
prompt = "Project Name"
|
||||
required = true
|
||||
default = "my-rust-project"
|
||||
nickel_path = ["project", "name"]
|
||||
|
||||
[[elements]]
|
||||
name = "project_description"
|
||||
type = "text"
|
||||
prompt = "Project Description"
|
||||
required = true
|
||||
default = "A Rust project with CI/CD"
|
||||
nickel_path = ["project", "description"]
|
||||
|
||||
[[elements]]
|
||||
name = "project_repository"
|
||||
type = "text"
|
||||
prompt = "Repository URL"
|
||||
required = true
|
||||
default = "https://github.com/example/my-rust-project"
|
||||
nickel_path = ["project", "repository"]
|
||||
|
||||
# =============================================================================
|
||||
# GITHUB ACTIONS CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
[[elements]]
|
||||
name = "github_header"
|
||||
type = "section_header"
|
||||
title = "🚀 GitHub Actions Configuration"
|
||||
border_top = true
|
||||
border_bottom = true
|
||||
|
||||
[[elements]]
|
||||
name = "enable_github_actions"
|
||||
type = "confirm"
|
||||
prompt = "Enable GitHub Actions CI?"
|
||||
default = true
|
||||
nickel_path = ["ci", "github_actions", "enabled"]
|
||||
|
||||
[[elements]]
|
||||
name = "parallel_jobs"
|
||||
type = "text"
|
||||
prompt = "Parallel Jobs"
|
||||
default = "4"
|
||||
when = "enable_github_actions == true"
|
||||
help = "Number of parallel CI jobs (1-20)"
|
||||
nickel_path = ["ci", "github_actions", "parallel_jobs"]
|
||||
|
||||
[[elements]]
|
||||
name = "timeout_minutes"
|
||||
type = "text"
|
||||
prompt = "Job Timeout (minutes)"
|
||||
default = "60"
|
||||
when = "enable_github_actions == true"
|
||||
help = "Maximum duration for each job"
|
||||
nickel_path = ["ci", "github_actions", "timeout_minutes"]
|
||||
|
||||
# =============================================================================
|
||||
# RUST CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
[[elements]]
|
||||
name = "rust_header"
|
||||
type = "section_header"
|
||||
title = "🦀 Rust Configuration"
|
||||
border_top = true
|
||||
border_bottom = true
|
||||
when = "enable_github_actions == true"
|
||||
|
||||
[[elements]]
|
||||
name = "rust_version"
|
||||
type = "select"
|
||||
prompt = "Rust Version"
|
||||
default = "stable"
|
||||
when = "enable_github_actions == true"
|
||||
options = [
|
||||
{ value = "stable", label = "Stable - Latest stable release" },
|
||||
{ value = "nightly", label = "Nightly - Cutting edge features" },
|
||||
{ value = "1.70.0", label = "1.70.0 - Specific version" },
|
||||
]
|
||||
nickel_path = ["ci", "github_actions", "rust", "version"]
|
||||
|
||||
# =============================================================================
|
||||
# CACHING
|
||||
# =============================================================================
|
||||
|
||||
[[elements]]
|
||||
name = "cache_header"
|
||||
type = "section_header"
|
||||
title = "💾 Build Cache"
|
||||
border_top = true
|
||||
border_bottom = true
|
||||
when = "enable_github_actions == true"
|
||||
|
||||
[[elements]]
|
||||
name = "enable_cache"
|
||||
type = "confirm"
|
||||
prompt = "Enable dependency caching?"
|
||||
default = false
|
||||
when = "enable_github_actions == true"
|
||||
help = "Cache Cargo dependencies to speed up builds"
|
||||
nickel_path = ["ci", "github_actions", "cache", "enabled"]
|
||||
|
||||
# =============================================================================
|
||||
# TOOLS CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
[[elements]]
|
||||
name = "tools_header"
|
||||
type = "section_header"
|
||||
title = "🔧 CI Tools"
|
||||
border_top = true
|
||||
border_bottom = true
|
||||
when = "enable_github_actions == true"
|
||||
|
||||
[[elements]]
|
||||
name = "enable_clippy"
|
||||
type = "confirm"
|
||||
prompt = "Enable cargo clippy (linter)?"
|
||||
default = true
|
||||
when = "enable_github_actions == true"
|
||||
help = "Run Rust linter on code"
|
||||
nickel_path = ["ci", "github_actions", "tools", "clippy", "enabled"]
|
||||
|
||||
[[elements]]
|
||||
name = "clippy_args"
|
||||
type = "text"
|
||||
prompt = "Clippy Arguments"
|
||||
default = "-D warnings"
|
||||
when = "enable_github_actions == true && enable_clippy == true"
|
||||
help = "Command-line arguments for clippy"
|
||||
nickel_path = ["ci", "github_actions", "tools", "clippy", "args"]
|
||||
|
||||
[[elements]]
|
||||
name = "enable_rustfmt"
|
||||
type = "confirm"
|
||||
prompt = "Enable rustfmt (formatter)?"
|
||||
default = true
|
||||
when = "enable_github_actions == true"
|
||||
help = "Check code formatting"
|
||||
nickel_path = ["ci", "github_actions", "tools", "rustfmt", "enabled"]
|
||||
|
||||
[[elements]]
|
||||
name = "rustfmt_edition"
|
||||
type = "select"
|
||||
prompt = "Rust Edition for rustfmt"
|
||||
default = "2021"
|
||||
when = "enable_github_actions == true && enable_rustfmt == true"
|
||||
options = [
|
||||
{ value = "2015", label = "2015" },
|
||||
{ value = "2018", label = "2018" },
|
||||
{ value = "2021", label = "2021" },
|
||||
]
|
||||
nickel_path = ["ci", "github_actions", "tools", "rustfmt", "edition"]
|
||||
|
||||
[[elements]]
|
||||
name = "enable_cargo_audit"
|
||||
type = "confirm"
|
||||
prompt = "Enable cargo-audit (security)?"
|
||||
default = false
|
||||
when = "enable_github_actions == true"
|
||||
help = "Scan for security vulnerabilities"
|
||||
nickel_path = ["ci", "github_actions", "tools", "cargo_audit", "enabled"]
|
||||
|
||||
# =============================================================================
|
||||
# DEPLOYMENT
|
||||
# =============================================================================
|
||||
|
||||
[[elements]]
|
||||
name = "deployment_header"
|
||||
type = "section_header"
|
||||
title = "🚢 Deployment"
|
||||
border_top = true
|
||||
border_bottom = true
|
||||
|
||||
[[elements]]
|
||||
name = "enable_deployment"
|
||||
type = "confirm"
|
||||
prompt = "Enable deployment?"
|
||||
default = false
|
||||
help = "Automatically deploy on successful builds"
|
||||
nickel_path = ["deployment", "enabled"]
|
||||
|
||||
[[elements]]
|
||||
name = "deployment_environment"
|
||||
type = "select"
|
||||
prompt = "Deployment Environment"
|
||||
default = "production"
|
||||
when = "enable_deployment == true"
|
||||
options = [
|
||||
{ value = "development", label = "Development" },
|
||||
{ value = "staging", label = "Staging" },
|
||||
{ value = "production", label = "Production" },
|
||||
]
|
||||
nickel_path = ["deployment", "environment"]
|
||||
|
||||
[[elements]]
|
||||
name = "auto_deploy"
|
||||
type = "confirm"
|
||||
prompt = "Auto-deploy on main branch?"
|
||||
default = false
|
||||
when = "enable_deployment == true"
|
||||
help = "Automatically deploy when main branch is updated"
|
||||
nickel_path = ["deployment", "auto_deploy"]
|
||||
|
||||
# =============================================================================
|
||||
# SUMMARY
|
||||
# =============================================================================
|
||||
|
||||
[[elements]]
|
||||
name = "summary_header"
|
||||
type = "section_header"
|
||||
title = "✅ Review & Save"
|
||||
border_top = true
|
||||
|
||||
[[elements]]
|
||||
name = "summary"
|
||||
type = "section"
|
||||
content = "Review your configuration above. Click Submit to save and see what changed."
|
||||
48
examples/08-nickel-roundtrip/config.ncl
Normal file
48
examples/08-nickel-roundtrip/config.ncl
Normal file
@ -0,0 +1,48 @@
|
||||
# CI Configuration
|
||||
# Generated by TypeDialog - Edit via ci-configure.sh
|
||||
|
||||
{
|
||||
project = {
|
||||
name = "my-rust-project",
|
||||
description = "A Rust project with CI/CD",
|
||||
repository = "https://github.com/example/my-rust-project",
|
||||
},
|
||||
|
||||
ci = {
|
||||
github_actions = {
|
||||
enabled = true,
|
||||
parallel_jobs = 4,
|
||||
timeout_minutes = 60,
|
||||
|
||||
rust = {
|
||||
version = "stable",
|
||||
targets = ["x86_64-unknown-linux-gnu"],
|
||||
},
|
||||
|
||||
cache = {
|
||||
enabled = false,
|
||||
paths = [],
|
||||
},
|
||||
|
||||
tools = {
|
||||
clippy = {
|
||||
enabled = true,
|
||||
args = ["-D", "warnings"],
|
||||
},
|
||||
rustfmt = {
|
||||
enabled = true,
|
||||
edition = "2021",
|
||||
},
|
||||
cargo_audit = {
|
||||
enabled = false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
deployment = {
|
||||
enabled = false,
|
||||
environment = "production",
|
||||
auto_deploy = false,
|
||||
},
|
||||
}
|
||||
52
examples/08-nickel-roundtrip/config.ncl.j2
Normal file
52
examples/08-nickel-roundtrip/config.ncl.j2
Normal file
@ -0,0 +1,52 @@
|
||||
# CI Configuration
|
||||
# Generated by TypeDialog - Edit via ci-configure.sh
|
||||
|
||||
{
|
||||
project = {
|
||||
name = "{{ project_name }}",
|
||||
description = "{{ project_description }}",
|
||||
repository = "{{ project_repository }}",
|
||||
},
|
||||
|
||||
ci = {
|
||||
github_actions = {
|
||||
enabled = {{ enable_github_actions }},
|
||||
parallel_jobs = {{ parallel_jobs }},
|
||||
timeout_minutes = {{ timeout_minutes }},
|
||||
|
||||
rust = {
|
||||
version = "{{ rust_version }}",
|
||||
targets = ["x86_64-unknown-linux-gnu"],
|
||||
},
|
||||
|
||||
cache = {
|
||||
enabled = {{ enable_cache }},
|
||||
paths = [],
|
||||
},
|
||||
|
||||
tools = {
|
||||
clippy = {
|
||||
enabled = {{ enable_clippy }},
|
||||
{% if enable_clippy and clippy_args %}
|
||||
args = {{ clippy_args | split(pat=" ") | json }},
|
||||
{% else %}
|
||||
args = [],
|
||||
{% endif %}
|
||||
},
|
||||
rustfmt = {
|
||||
enabled = {{ enable_rustfmt }},
|
||||
edition = "{{ rustfmt_edition }}",
|
||||
},
|
||||
cargo_audit = {
|
||||
enabled = {{ enable_cargo_audit }},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
deployment = {
|
||||
enabled = {{ enable_deployment }},
|
||||
environment = "{{ deployment_environment }}",
|
||||
auto_deploy = {{ auto_deploy }},
|
||||
},
|
||||
}
|
||||
256
examples/13-conditional-logic/README.md
Normal file
256
examples/13-conditional-logic/README.md
Normal file
@ -0,0 +1,256 @@
|
||||
# Conditional Logic Examples
|
||||
|
||||
This directory demonstrates all supported conditional operators in TypeDialog forms.
|
||||
|
||||
## Overview
|
||||
|
||||
TypeDialog supports rich conditional logic for dynamic form behavior. Fields can be shown/hidden based on previous answers using the `when` attribute.
|
||||
|
||||
## Running the Examples
|
||||
|
||||
### CLI Backend
|
||||
|
||||
```bash
|
||||
cargo run --bin typedialog -- examples/13-conditional-logic/conditional-demo.toml
|
||||
```
|
||||
|
||||
### TUI Backend
|
||||
|
||||
```bash
|
||||
cargo run --bin typedialog-tui -- examples/13-conditional-logic/conditional-demo.toml
|
||||
```
|
||||
|
||||
### Web Backend
|
||||
|
||||
```bash
|
||||
cargo run --bin typedialog-web -- examples/13-conditional-logic/conditional-demo.toml
|
||||
```
|
||||
|
||||
## Supported Operators
|
||||
|
||||
### Comparison Operators
|
||||
|
||||
#### Equality and Inequality
|
||||
|
||||
- `==`: Equal to
|
||||
- `!=`: Not equal to
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "mysql_config"
|
||||
when = "database_driver == mysql"
|
||||
|
||||
[[elements]]
|
||||
name = "server_warning"
|
||||
when = "database_driver != sqlite"
|
||||
```
|
||||
|
||||
#### Numeric Comparisons
|
||||
|
||||
- `>`: Greater than
|
||||
- `<`: Less than
|
||||
- `>=`: Greater than or equal
|
||||
- `<=`: Less than or equal
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "privileged_port_warning"
|
||||
when = "server_port < 1024"
|
||||
|
||||
[[elements]]
|
||||
name = "high_port_warning"
|
||||
when = "server_port > 10000"
|
||||
```
|
||||
|
||||
### String Operators
|
||||
|
||||
- `contains`: Check if field contains substring
|
||||
- `startswith`: Check if field starts with prefix
|
||||
- `endswith`: Check if field ends with suffix
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "https_notice"
|
||||
when = "project_url startswith https"
|
||||
|
||||
[[elements]]
|
||||
name = "github_specific"
|
||||
when = "project_url endswith github.com"
|
||||
|
||||
[[elements]]
|
||||
name = "gitlab_ci"
|
||||
when = "project_url contains gitlab"
|
||||
```
|
||||
|
||||
### Array Membership
|
||||
|
||||
- `in`: Check if value exists in array field
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "rust_toolchain"
|
||||
when = "rust in detected_languages"
|
||||
|
||||
[[elements]]
|
||||
name = "python_venv"
|
||||
when = "python in detected_languages"
|
||||
```
|
||||
|
||||
**Works with**:
|
||||
|
||||
- MultiSelect fields (JSON array or comma-separated string)
|
||||
- Array values from JSON output
|
||||
|
||||
### File System Conditions
|
||||
|
||||
- `file_exists(path)`: Check if file or directory exists
|
||||
- `!file_exists(path)`: Check if file does NOT exist (negation)
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "dockerfile_exists_notice"
|
||||
when = "file_exists(Dockerfile)"
|
||||
|
||||
[[elements]]
|
||||
name = "create_dockerfile"
|
||||
when = "!file_exists(Dockerfile)"
|
||||
|
||||
[[elements]]
|
||||
name = "env_setup"
|
||||
type = "group"
|
||||
includes = ["fragments/environment-setup.toml"]
|
||||
when = "!file_exists(.env)"
|
||||
```
|
||||
|
||||
**Path resolution**:
|
||||
|
||||
- Absolute paths: `/etc/config.toml`
|
||||
- Relative paths: `Dockerfile`, `.env`, `config/settings.toml`
|
||||
- Works with both files and directories
|
||||
|
||||
## Form Flow Example
|
||||
|
||||
1. **Select database** → Shows driver-specific fields
|
||||
2. **Enter port number** → Shows warnings based on port range
|
||||
3. **Enter project URL** → Shows platform-specific options
|
||||
4. **Select languages** → Shows language-specific tooling
|
||||
5. **Check for files** → Conditionally load setup fragments
|
||||
|
||||
## Key Features
|
||||
|
||||
### Dynamic Field Visibility
|
||||
|
||||
Fields appear/disappear based on user input:
|
||||
|
||||
```toml
|
||||
# Only shows if user selects MySQL
|
||||
[[elements]]
|
||||
name = "mysql_password"
|
||||
type = "password"
|
||||
when = "database_driver == mysql"
|
||||
```
|
||||
|
||||
### Conditional Fragment Loading
|
||||
|
||||
Load entire form sections conditionally:
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "docker_setup"
|
||||
type = "group"
|
||||
includes = ["fragments/docker-init.toml"]
|
||||
when = "!file_exists(Dockerfile)"
|
||||
```
|
||||
|
||||
### Multi-Condition Fields
|
||||
|
||||
Same field can have complex logic (future: `&&` and `||`):
|
||||
|
||||
```toml
|
||||
# Current: Single condition per field
|
||||
when = "rust in detected_languages"
|
||||
|
||||
# Future: Compound conditions
|
||||
when = "(rust in detected_languages) && (server_port >= 1024)"
|
||||
```
|
||||
|
||||
## Testing Scenarios
|
||||
|
||||
### Scenario 1: Database Configuration
|
||||
|
||||
1. Select `mysql` → See MySQL-specific fields
|
||||
2. Select `postgresql` → See PostgreSQL-specific fields
|
||||
3. Select `sqlite` → No extra server fields
|
||||
|
||||
### Scenario 2: Port Validation
|
||||
|
||||
1. Enter `80` → See privileged port warning (`< 1024`)
|
||||
2. Enter `8080` → See standard port notice (`>= 1024`)
|
||||
3. Enter `15000` → See high port warning (`> 10000`)
|
||||
|
||||
### Scenario 3: Language Detection
|
||||
|
||||
1. Select `rust` + `python` → See both Rust and Python config fields
|
||||
2. Select only `javascript` → See only Node.js version selector
|
||||
3. Select none → No language-specific fields appear
|
||||
|
||||
### Scenario 4: File System Checks
|
||||
|
||||
1. **With Dockerfile present**:
|
||||
- Shows "Dockerfile found" notice
|
||||
- Skips Docker setup wizard
|
||||
|
||||
2. **Without Dockerfile**:
|
||||
- Asks to create Dockerfile
|
||||
- Shows Docker setup group
|
||||
|
||||
3. **With .env file**:
|
||||
- Asks to use existing config
|
||||
- Skips environment setup
|
||||
|
||||
4. **Without .env file**:
|
||||
- Loads environment setup fragment
|
||||
- Prompts for all env variables
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Condition Evaluation
|
||||
|
||||
Conditions are evaluated at runtime during form execution:
|
||||
|
||||
1. User answers a field
|
||||
2. System checks all pending fields for `when` conditions
|
||||
3. Fields with satisfied conditions become visible
|
||||
4. Fields with unsatisfied conditions remain hidden
|
||||
|
||||
### Fragment Loading
|
||||
|
||||
Conditional groups with `includes` load fragments only when condition is true:
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "advanced_config"
|
||||
type = "group"
|
||||
includes = ["fragments/advanced-settings.toml"]
|
||||
when = "enable_advanced == true"
|
||||
```
|
||||
|
||||
This prevents loading unnecessary form definitions until needed.
|
||||
|
||||
### Type Conversion
|
||||
|
||||
TypeDialog automatically handles type conversions in conditions:
|
||||
|
||||
- String `"8080"` compared to number `1024` → converted to number
|
||||
- Boolean `true` compared to string `"true"` → compared as boolean
|
||||
- Number compared to string number → compared numerically
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Field Types Reference](../../docs/field_types.md) - All field types and attributes
|
||||
- [Fragment System](../../docs/fragment-search-paths.md) - Dynamic form composition
|
||||
- [Nickel Integration](../../docs/nickel.md) - Schema-driven forms
|
||||
|
||||
## Source Code
|
||||
|
||||
Condition evaluation logic: `crates/typedialog-core/src/form_parser/conditions.rs`
|
||||
198
examples/13-conditional-logic/conditional-demo.toml
Normal file
198
examples/13-conditional-logic/conditional-demo.toml
Normal file
@ -0,0 +1,198 @@
|
||||
# Conditional Logic Demo
|
||||
# Demonstrates all supported conditional operators in TypeDialog
|
||||
|
||||
name = "conditional_demo"
|
||||
description = "Complete demonstration of conditional field visibility"
|
||||
|
||||
# ====================
|
||||
# COMPARISON OPERATORS
|
||||
# ====================
|
||||
|
||||
[[elements]]
|
||||
name = "database_driver"
|
||||
type = "select"
|
||||
prompt = "Select database driver"
|
||||
required = true
|
||||
options = [
|
||||
{ value = "sqlite", label = "SQLite (embedded)" },
|
||||
{ value = "mysql", label = "MySQL" },
|
||||
{ value = "postgresql", label = "PostgreSQL" }
|
||||
]
|
||||
|
||||
# Equality (==)
|
||||
[[elements]]
|
||||
name = "mysql_config"
|
||||
type = "text"
|
||||
prompt = "MySQL connection string"
|
||||
when = "database_driver == mysql"
|
||||
placeholder = "mysql://localhost:3306/db"
|
||||
|
||||
# Inequality (!=)
|
||||
[[elements]]
|
||||
name = "server_warning"
|
||||
type = "section"
|
||||
content = "⚠️ You selected a server-based database. Ensure the server is running."
|
||||
when = "database_driver != sqlite"
|
||||
|
||||
# ====================
|
||||
# NUMERIC COMPARISONS
|
||||
# ====================
|
||||
|
||||
[[elements]]
|
||||
name = "server_port"
|
||||
type = "text"
|
||||
prompt = "Server port"
|
||||
default = "8080"
|
||||
required = true
|
||||
|
||||
# Greater than (>)
|
||||
[[elements]]
|
||||
name = "high_port_warning"
|
||||
type = "section"
|
||||
content = "⚠️ Port > 10000 is uncommon. Double-check your configuration."
|
||||
when = "server_port > 10000"
|
||||
|
||||
# Less than (<)
|
||||
[[elements]]
|
||||
name = "privileged_port_warning"
|
||||
type = "section"
|
||||
content = "⚠️ Port < 1024 requires root/admin privileges."
|
||||
when = "server_port < 1024"
|
||||
|
||||
# Greater than or equal (>=)
|
||||
[[elements]]
|
||||
name = "standard_port_notice"
|
||||
type = "section"
|
||||
content = "✓ Using standard user port range (>= 1024)"
|
||||
when = "server_port >= 1024"
|
||||
|
||||
# Less than or equal (<=)
|
||||
[[elements]]
|
||||
name = "low_port_range"
|
||||
type = "section"
|
||||
content = "Using low port range (<= 5000)"
|
||||
when = "server_port <= 5000"
|
||||
|
||||
# ====================
|
||||
# STRING OPERATORS
|
||||
# ====================
|
||||
|
||||
[[elements]]
|
||||
name = "project_url"
|
||||
type = "text"
|
||||
prompt = "Project repository URL"
|
||||
placeholder = "https://github.com/user/repo"
|
||||
|
||||
# startswith
|
||||
[[elements]]
|
||||
name = "https_notice"
|
||||
type = "section"
|
||||
content = "✓ Secure HTTPS URL detected"
|
||||
when = "project_url startswith https"
|
||||
|
||||
# endswith
|
||||
[[elements]]
|
||||
name = "github_specific"
|
||||
type = "text"
|
||||
prompt = "GitHub Actions enabled?"
|
||||
when = "project_url endswith github.com"
|
||||
|
||||
# contains
|
||||
[[elements]]
|
||||
name = "gitlab_ci"
|
||||
type = "confirm"
|
||||
prompt = "Enable GitLab CI integration?"
|
||||
when = "project_url contains gitlab"
|
||||
|
||||
# ====================
|
||||
# ARRAY MEMBERSHIP (in)
|
||||
# ====================
|
||||
|
||||
[[elements]]
|
||||
name = "detected_languages"
|
||||
type = "multiselect"
|
||||
prompt = "Which languages are used in your project?"
|
||||
display_mode = "grid"
|
||||
options = [
|
||||
{ value = "rust", label = "🦀 Rust" },
|
||||
{ value = "python", label = "🐍 Python" },
|
||||
{ value = "javascript", label = "📜 JavaScript" },
|
||||
{ value = "go", label = "🐹 Go" }
|
||||
]
|
||||
|
||||
# Array membership check
|
||||
[[elements]]
|
||||
name = "rust_toolchain"
|
||||
type = "select"
|
||||
prompt = "Rust toolchain version"
|
||||
when = "rust in detected_languages"
|
||||
options = [
|
||||
{ value = "stable", label = "Stable" },
|
||||
{ value = "nightly", label = "Nightly" },
|
||||
{ value = "beta", label = "Beta" }
|
||||
]
|
||||
|
||||
[[elements]]
|
||||
name = "python_venv"
|
||||
type = "confirm"
|
||||
prompt = "Use Python virtual environment?"
|
||||
when = "python in detected_languages"
|
||||
default = true
|
||||
|
||||
[[elements]]
|
||||
name = "nodejs_version"
|
||||
type = "select"
|
||||
prompt = "Node.js version"
|
||||
when = "javascript in detected_languages"
|
||||
options = [
|
||||
{ value = "18", label = "Node.js 18 LTS" },
|
||||
{ value = "20", label = "Node.js 20 LTS" },
|
||||
{ value = "latest", label = "Latest" }
|
||||
]
|
||||
|
||||
# ====================
|
||||
# FILE SYSTEM CONDITIONS
|
||||
# ====================
|
||||
|
||||
# file_exists(path)
|
||||
[[elements]]
|
||||
name = "dockerfile_exists_notice"
|
||||
type = "section"
|
||||
content = "✓ Dockerfile found in current directory"
|
||||
when = "file_exists(Dockerfile)"
|
||||
|
||||
# !file_exists(path) - negation
|
||||
[[elements]]
|
||||
name = "create_dockerfile"
|
||||
type = "confirm"
|
||||
prompt = "No Dockerfile found. Create one?"
|
||||
when = "!file_exists(Dockerfile)"
|
||||
default = true
|
||||
|
||||
[[elements]]
|
||||
name = "use_existing_config"
|
||||
type = "confirm"
|
||||
prompt = "Existing .env file found. Use existing configuration?"
|
||||
when = "file_exists(.env)"
|
||||
|
||||
[[elements]]
|
||||
name = "env_setup"
|
||||
type = "group"
|
||||
includes = ["fragments/environment-setup.toml"]
|
||||
when = "!file_exists(.env)"
|
||||
|
||||
# ====================
|
||||
# COMBINED CONDITIONS
|
||||
# ====================
|
||||
|
||||
[[elements]]
|
||||
name = "rust_docker_setup"
|
||||
type = "section"
|
||||
content = "🦀 Rust + Docker detected. Consider using rust:alpine base image."
|
||||
when = "rust in detected_languages"
|
||||
|
||||
[[elements]]
|
||||
name = "production_ready_check"
|
||||
type = "section"
|
||||
content = "✅ Production-ready configuration detected (HTTPS + standard port)"
|
||||
when = "server_port >= 1024"
|
||||
@ -0,0 +1,72 @@
|
||||
# Environment Setup Fragment
|
||||
# Loaded conditionally when .env file doesn't exist
|
||||
|
||||
name = "environment_setup"
|
||||
description = "Environment variables configuration"
|
||||
display_mode = "complete"
|
||||
|
||||
[[elements]]
|
||||
name = "env_header"
|
||||
type = "section_header"
|
||||
title = "🔧 Environment Configuration"
|
||||
border_top = true
|
||||
border_bottom = true
|
||||
|
||||
[[elements]]
|
||||
name = "env_mode"
|
||||
type = "select"
|
||||
prompt = "Environment mode"
|
||||
required = true
|
||||
options = [
|
||||
{ value = "development", label = "Development" },
|
||||
{ value = "staging", label = "Staging" },
|
||||
{ value = "production", label = "Production" }
|
||||
]
|
||||
|
||||
[[elements]]
|
||||
name = "log_level"
|
||||
type = "select"
|
||||
prompt = "Log level"
|
||||
default = "info"
|
||||
options = [
|
||||
{ value = "trace", label = "Trace (verbose)" },
|
||||
{ value = "debug", label = "Debug" },
|
||||
{ value = "info", label = "Info" },
|
||||
{ value = "warn", label = "Warning" },
|
||||
{ value = "error", label = "Error" }
|
||||
]
|
||||
|
||||
[[elements]]
|
||||
name = "debug_mode"
|
||||
type = "confirm"
|
||||
prompt = "Enable debug mode?"
|
||||
when = "env_mode == development"
|
||||
default = true
|
||||
|
||||
[[elements]]
|
||||
name = "api_key"
|
||||
type = "password"
|
||||
prompt = "API Key"
|
||||
required = true
|
||||
help = "Your application API key"
|
||||
|
||||
[[elements]]
|
||||
name = "database_url"
|
||||
type = "text"
|
||||
prompt = "Database URL"
|
||||
placeholder = "postgresql://localhost:5432/mydb"
|
||||
required = true
|
||||
|
||||
[[elements]]
|
||||
name = "redis_url"
|
||||
type = "text"
|
||||
prompt = "Redis URL"
|
||||
placeholder = "redis://localhost:6379"
|
||||
when = "env_mode == production"
|
||||
|
||||
[[elements]]
|
||||
name = "sentry_dsn"
|
||||
type = "text"
|
||||
prompt = "Sentry DSN (error tracking)"
|
||||
when = "env_mode == production"
|
||||
help = "Leave empty to disable error tracking"
|
||||
@ -189,6 +189,41 @@ cat examples/12-agent-execution/README.md
|
||||
- [Tests](../tests/agent/) - Agent validation tests
|
||||
- [Core Examples](../crates/typedialog-agent/typedialog-ag-core/examples/) - Rust API usage
|
||||
|
||||
### 13. **Conditional Logic** → [`13-conditional-logic/`](13-conditional-logic/)
|
||||
|
||||
Complete guide to all conditional operators and expressions.
|
||||
|
||||
**Operators demonstrated:**
|
||||
|
||||
| Category | Operators | Example |
|
||||
|----------|-----------|---------|
|
||||
| **Comparison** | `==`, `!=`, `>`, `<`, `>=`, `<=` | `port >= 1024` |
|
||||
| **String** | `contains`, `startswith`, `endswith` | `url startswith https` |
|
||||
| **Array** | `in` | `rust in detected_languages` |
|
||||
| **File System** | `file_exists()`, `!file_exists()` | `!file_exists(Dockerfile)` |
|
||||
|
||||
**Features:**
|
||||
|
||||
- Dynamic field visibility based on user input
|
||||
- Conditional fragment loading
|
||||
- Multi-condition scenarios
|
||||
- Type-safe comparisons
|
||||
|
||||
**Running the example:**
|
||||
|
||||
```bash
|
||||
# CLI
|
||||
cargo run --bin typedialog -- examples/13-conditional-logic/conditional-demo.toml
|
||||
|
||||
# TUI
|
||||
cargo run --bin typedialog-tui -- examples/13-conditional-logic/conditional-demo.toml
|
||||
|
||||
# Web
|
||||
cargo run --bin typedialog-web -- examples/13-conditional-logic/conditional-demo.toml
|
||||
```
|
||||
|
||||
**Best for:** Dynamic forms, adaptive UX, configuration wizards
|
||||
|
||||
## Learning Path
|
||||
|
||||
```
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user