Compare commits

..

9 Commits

Author SHA1 Message Date
Jesús Pérez
5b0dbd30fd
chore: init tracing logs and fix criterion black_box
Some checks failed
CI / Lint (bash) (push) Has been cancelled
CI / Lint (markdown) (push) Has been cancelled
CI / Lint (nickel) (push) Has been cancelled
CI / Lint (nushell) (push) Has been cancelled
CI / Lint (rust) (push) Has been cancelled
CI / Benchmark (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / License Compliance (push) Has been cancelled
CI / Code Coverage (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
2025-12-28 20:16:19 +00:00
Jesús Pérez
38f07ded1d
chore: update crages version and add tracing 2025-12-28 20:14:01 +00:00
Jesús Pérez
3d9c28f7f7
chore: update backends output for config files changes 2025-12-28 19:46:54 +00:00
Jesús Pérez
30b5b4797e
chore: Stack Overflow bug in nickel-roundtrip 2025-12-28 18:56:17 +00:00
Jesús Pérez
39e5c35a28
chore: add new conditionals to docs 2025-12-28 18:28:34 +00:00
Jesús Pérez
f7f7fec13b
chore: add \!file_exists condition 2025-12-28 18:16:50 +00:00
Jesús Pérez
25e779a390
chore: fix multiselect change and other selector values, fix defaults on them 2025-12-28 17:54:25 +00:00
Jesús Pérez
f4d3a6472b
chore: add docs and example about nickel-roundtrip 2025-12-28 13:47:49 +00:00
Jesús Pérez
2e75e2106c
chore: improve submit page and end info with backends 2025-12-28 13:29:23 +00:00
39 changed files with 3524 additions and 231 deletions

475
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -188,6 +188,7 @@ mod tests {
default: None,
placeholder: None,
options: Vec::new(),
options_from: None,
required: None,
file_extension: None,
prefix_text: None,

View File

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

View File

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

View File

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

View File

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

View File

@ -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('.') {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

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

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

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

View 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

View 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."

View 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,
},
}

View 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 }},
},
}

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

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

View File

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

View File

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