diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000000..fc674f1b6eb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +# Generated by gunbc - minimal YAML shim +# All CI logic is in testable Rust code, not YAML +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-action@stable + + - name: Run CI + run: cargo run -p gunbc-ci -- run diff --git a/.gitignore b/.gitignore index 97c93c9fd51..0831a1dee2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,27 @@ -# Cargo build artifacts — reproducible from source +# Generated by gunbc-bootstrap + +# Rust /target/ +**/*.rs.bk +Cargo.lock -# Editor/IDE state +# IDE .idea/ .vscode/ *.swp *.swo *~ -# OS metadata +# OS .DS_Store Thumbs.db -# Secrets / local config -.env -.env.local -.env.*.local +# Build artifacts +*.o +*.a +*.so +*.dylib + +# Generated files (keep these tracked) +# Makefile +# .github/workflows/ci.yml diff --git a/Cargo.lock b/Cargo.lock index eece5732601..0bbbb42a16b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,274 +3,158 @@ version = 4 [[package]] -name = "aho-corasick" -version = "1.1.4" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +name = "gunbc-bootstrap" +version = "0.1.0" dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", + "gunbc-codegen", + "gunbc-exec", + "gunbc-ir", ] [[package]] -name = "anstyle" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +name = "gunbc-buck2" +version = "0.1.0" dependencies = [ - "utf8parse", + "gunbc-exec", + "gunbc-ir", + "gunbc-test", + "gunbc-transport", + "serde_json", + "toml", ] [[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +name = "gunbc-ci" +version = "0.1.0" dependencies = [ - "windows-sys", + "gunbc-exec", + "gunbc-ir", ] [[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +name = "gunbc-codegen" +version = "0.1.0" dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys", + "gunbc-ir", ] [[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "bstr" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +name = "gunbc-deps" +version = "0.1.0" dependencies = [ - "memchr", + "gunbc-exec", + "gunbc-ir", + "gunbc-test", "serde", + "toml", ] [[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "clap" -version = "4.5.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e34525d5bbbd55da2bb745d34b36121baac88d07619a9a09cfcf4a6c0832785" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a20016a20a3da95bef50ec7238dbd09baeef4311dcdd38ec15aba69812fb61" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" - -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +name = "gunbc-exec" +version = "0.1.0" dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", + "gunbc-ir", + "thiserror", ] [[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +name = "gunbc-gist" +version = "0.1.0" dependencies = [ - "crossbeam-utils", + "gunbc-exec", + "gunbc-ir", + "gunbc-test", + "gunbc-transport", ] [[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +name = "gunbc-ir" +version = "0.1.0" dependencies = [ - "libc", - "windows-sys", + "serde", + "serde_json", ] [[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +name = "gunbc-makegen" +version = "0.1.0" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", + "gunbc-codegen", + "gunbc-exec", + "gunbc-ir", + "gunbc-test", + "serde_json", ] [[package]] -name = "globset" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +name = "gunbc-test" +version = "0.1.0" dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", + "gunbc-exec", + "gunbc-ir", ] [[package]] -name = "gunbc-exec" +name = "gunbc-testgen" version = "0.1.0" dependencies = [ "gunbc-ir", ] [[package]] -name = "gunbc-gistgen" +name = "gunbc-transport" version = "0.1.0" dependencies = [ - "clap", - "globset", "gunbc-exec", "gunbc-ir", - "gunbc-validate", - "ignore", - "tempfile", + "serde_json", ] [[package]] -name = "gunbc-ir" -version = "0.1.0" - -[[package]] -name = "gunbc-validate" +name = "gunbc-viz" version = "0.1.0" dependencies = [ + "gunbc-bootstrap", + "gunbc-buck2", + "gunbc-ci", + "gunbc-deps", + "gunbc-exec", + "gunbc-gist", "gunbc-ir", + "gunbc-makegen", + "gunbc-test", + "gunbc-transport", + "serde", + "serde_json", ] [[package]] -name = "heck" -version = "0.5.0" +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] -name = "ignore" -version = "0.4.25" +name = "indexmap" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", + "equivalent", + "hashbrown", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "libc" -version = "0.2.180" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] -name = "log" -version = "0.4.29" +name = "itoa" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "memchr" @@ -278,18 +162,6 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "proc-macro2" version = "1.0.106" @@ -308,51 +180,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "regex-automata" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - -[[package]] -name = "rustix" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - [[package]] name = "serde" version = "1.0.228" @@ -360,6 +187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -383,10 +211,26 @@ dependencies = [ ] [[package]] -name = "strsim" -version = "0.11.1" +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] [[package]] name = "syn" @@ -400,75 +244,83 @@ dependencies = [ ] [[package]] -name = "tempfile" -version = "3.24.0" +name = "thiserror" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", + "thiserror-impl", ] [[package]] -name = "unicode-ident" -version = "1.0.22" +name = "thiserror-impl" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "utf8parse" -version = "0.2.2" +name = "toml" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] [[package]] -name = "walkdir" -version = "2.5.0" +name = "toml_datetime" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ - "same-file", - "winapi-util", + "serde", ] [[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" +name = "toml_edit" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "wit-bindgen", + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", ] [[package]] -name = "winapi-util" -version = "0.1.11" +name = "toml_write" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys", -] +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] -name = "windows-link" -version = "0.2.1" +name = "unicode-ident" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] -name = "windows-sys" -version = "0.61.2" +name = "winnow" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ - "windows-link", + "memchr", ] [[package]] -name = "wit-bindgen" -version = "0.51.0" +name = "zmij" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" diff --git a/Cargo.toml b/Cargo.toml index a76455f72d1..e12a57f8254 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,32 @@ resolver = "2" members = [ "crates/gunbc-ir", - "crates/gunbc-validate", "crates/gunbc-exec", - "crates/gunbc-gistgen", + "crates/gunbc-test", + "crates/gunbc-testgen", + "crates/gunbc-codegen", + "crates/gunbc-transport", + "crates/gunbc-makegen", + "crates/gunbc-deps", + "crates/gunbc-ci", + "crates/gunbc-bootstrap", + "crates/gunbc-gist", + "crates/gunbc-buck2", + "crates/gunbc-viz", ] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" + +[workspace.dependencies] +gunbc-ir = { path = "crates/gunbc-ir" } +gunbc-exec = { path = "crates/gunbc-exec" } +gunbc-test = { path = "crates/gunbc-test" } +gunbc-codegen = { path = "crates/gunbc-codegen" } +gunbc-transport = { path = "crates/gunbc-transport" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "1.0" +toml = "0.8" diff --git a/Makefile b/Makefile new file mode 100644 index 00000000000..c2919b655f5 --- /dev/null +++ b/Makefile @@ -0,0 +1,64 @@ +# Generated by gunbc-bootstrap +# Regenerate with: make bootstrap + +.DEFAULT_GOAL := help + +.PHONY: help build test lint ci gist buck2 makegen deps bootstrap + +help: + @echo "gunbc - DAG-based code generation framework" + @echo "" + @echo "Development:" + @echo " build Build all targets" + @echo " test Run all tests" + @echo " lint Run clippy" + @echo " ci Run full CI pipeline" + @echo "" + @echo "Tools:" + @echo " gist Create GitHub gist from code" + @echo " buck2 Generate BUCK file" + @echo " makegen Regenerate this Makefile" + @echo " deps Install/check dependencies" + @echo " bootstrap Regenerate all build infrastructure" + +build: + cargo build --all-targets + +test: + cargo test + +lint: + cargo clippy --all-targets -- -D warnings + +ci: + cargo run -p gunbc-ci -- run + +gist: + cargo run -p gunbc-gist -- $(if $(REPO),--repo $(REPO)) $(if $(EXT),-e $(EXT)) + +gist-dry: + cargo run -p gunbc-gist -- --dry-run $(if $(REPO),--repo $(REPO)) + +buck2: + cargo run -p gunbc-buck2 -- $(if $(INPUT),--input $(INPUT)) + +buck2-dry: + cargo run -p gunbc-buck2 -- --dry-run + +makegen: + cargo run -p gunbc-makegen + +makegen-dry: + cargo run -p gunbc-makegen -- --dry-run + +deps: + cargo run -p gunbc-deps -- install + +deps-list: + cargo run -p gunbc-deps -- --list + +bootstrap: + cargo run -p gunbc-bootstrap + +bootstrap-dry: + cargo run -p gunbc-bootstrap -- --dry-run diff --git a/crates/gunbc-bootstrap/Cargo.toml b/crates/gunbc-bootstrap/Cargo.toml new file mode 100644 index 00000000000..88f3b8fba61 --- /dev/null +++ b/crates/gunbc-bootstrap/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "gunbc-bootstrap" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Bootstrap tool that generates all gunbc build infrastructure" + +[dependencies] +gunbc-ir = { workspace = true } +gunbc-exec = { workspace = true } +gunbc-codegen = { workspace = true } + +[[bin]] +name = "gunbc-bootstrap" +path = "src/main.rs" diff --git a/crates/gunbc-bootstrap/src/graph.rs b/crates/gunbc-bootstrap/src/graph.rs new file mode 100644 index 00000000000..75f7fd3317c --- /dev/null +++ b/crates/gunbc-bootstrap/src/graph.rs @@ -0,0 +1,118 @@ +//! Graph builder for the bootstrap tool. + +use crate::ops::BootstrapOp; +use gunbc_ir::{build::*, Dag, Edge, Node}; + +/// Build the bootstrap graph. +/// +/// Pipeline: +/// ```text +/// ScanWorkspace -> GenerateMakefile -> WriteFiles +/// -> GenerateGitignore -/ +/// (boundary) +/// ``` +pub fn build_bootstrap_graph() -> Dag { + let mut dag = Dag::new(); + + // Node: ScanWorkspace + dag.add_node(Node::opaque( + "scan_workspace", + vec![], + vec![ + port("crate_count", "Int"), + port("crate_names", "StrList"), + ], + BootstrapOp::ScanWorkspace, + )); + + // Node: GenerateMakefile + dag.add_node(Node::opaque( + "generate_makefile", + vec![port("crate_names", "StrList")], + vec![port("makefile_content", "String")], + BootstrapOp::GenerateMakefile, + )); + + // Node: GenerateGitignore + dag.add_node(Node::opaque( + "generate_gitignore", + vec![port("crate_names", "StrList")], + vec![port("gitignore_content", "String")], + BootstrapOp::GenerateGitignore, + )); + + // Node: WriteFiles (BOUNDARY) + dag.add_node(Node::opaque( + "write_files", + vec![ + port("makefile_content", "String"), + port("gitignore_content", "String"), + ], + vec![ + port("files_written", "StrList"), + port("write_count", "Int"), + ], + BootstrapOp::WriteFiles, + )); + + // Wire up the pipeline + dag.add_edge(Edge::new( + "scan_workspace", + "crate_names", + "generate_makefile", + "crate_names", + )); + dag.add_edge(Edge::new( + "scan_workspace", + "crate_names", + "generate_gitignore", + "crate_names", + )); + dag.add_edge(Edge::new( + "generate_makefile", + "makefile_content", + "write_files", + "makefile_content", + )); + dag.add_edge(Edge::new( + "generate_gitignore", + "gitignore_content", + "write_files", + "gitignore_content", + )); + + dag +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::{detect_boundaries, detect_entrypoints}; + + #[test] + fn test_graph_has_boundary() { + let dag = build_bootstrap_graph(); + let boundaries = detect_boundaries(&dag); + + // WriteFiles should be a boundary + assert!(boundaries.is_boundary_node(&"write_files".into())); + } + + #[test] + fn test_graph_no_entrypoints() { + let dag = build_bootstrap_graph(); + let entrypoints = detect_entrypoints(&dag); + + // ScanWorkspace has no inputs, so nothing should be an entrypoint + // (entrypoints are inputs without upstream, but scan_workspace has no inputs at all) + assert!(entrypoints.entrypoint_ports.is_empty()); + } + + #[test] + fn test_graph_structure() { + let dag = build_bootstrap_graph(); + + assert_eq!(dag.nodes.len(), 4); + assert_eq!(dag.edges.len(), 4); + } +} diff --git a/crates/gunbc-bootstrap/src/lib.rs b/crates/gunbc-bootstrap/src/lib.rs new file mode 100644 index 00000000000..e0e3fa45210 --- /dev/null +++ b/crates/gunbc-bootstrap/src/lib.rs @@ -0,0 +1,15 @@ +//! gunbc-bootstrap: Generate all build infrastructure. +//! +//! This tool generates: +//! - Makefile (via gunbc-makegen logic) +//! - .gitignore +//! - deps.toml template +//! - CI workflow +//! +//! All outputs are boundaries (file writes), all dry-runnable. + +pub mod graph; +pub mod ops; + +pub use graph::build_bootstrap_graph; +pub use ops::BootstrapOp; diff --git a/crates/gunbc-bootstrap/src/main.rs b/crates/gunbc-bootstrap/src/main.rs new file mode 100644 index 00000000000..ae4dc276efa --- /dev/null +++ b/crates/gunbc-bootstrap/src/main.rs @@ -0,0 +1,114 @@ +//! CLI for gunbc-bootstrap. + +use gunbc_bootstrap::build_bootstrap_graph; +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_ir::Value; +use std::env; +use std::process; + +fn main() { + let args: Vec = env::args().collect(); + + let mut dry_run = false; + + // Simple argument parsing + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--dry-run" | "-n" => { + dry_run = true; + } + "--help" | "-h" => { + print_help(); + return; + } + _ => {} + } + i += 1; + } + + // Build the graph + let dag = build_bootstrap_graph(); + + // Set up execution mode + let mode = if dry_run { + let mut mocks = BoundaryMocks::new(); + mocks.set_value("write_files", "files_written", Value::StrList(vec![])); + mocks.set_value("write_files", "write_count", Value::Int(0)); + ExecutionMode::DryRun(mocks) + } else { + ExecutionMode::Real + }; + + println!("gunbc-bootstrap"); + println!(" mode: {}", if dry_run { "dry-run" } else { "generate" }); + println!(); + + match execute_with_mode(&dag, mode) { + Ok(log) => { + for entry in &log.entries { + let marker = if entry.was_intercepted { + " [DRY-RUN]" + } else { + "" + }; + println!("[{}]{}", entry.node_id, marker); + + // Print relevant outputs + for (port, value) in &entry.outputs { + match value { + Value::Str(s) if port.ends_with("_content") => { + if dry_run { + println!(" {}:", port); + println!("--- START ---"); + println!("{}", s); + println!("--- END ---"); + } else { + println!(" {}: ({} bytes)", port, s.len()); + } + } + Value::Str(s) if s.len() < 100 => println!(" {}: {}", port, s), + Value::StrList(list) if !list.is_empty() => { + println!(" {}: {}", port, list.join(", ")); + } + Value::StrList(_) => println!(" {}: (none)", port), + Value::Int(n) => println!(" {}: {}", port, n), + _ => {} + } + } + } + + // Summary + if !dry_run { + if let Some(entry) = log.get("write_files") { + if let Some(Value::StrList(files)) = entry.outputs.get("files_written") { + if files.is_empty() { + println!("\nNo files changed."); + } else { + println!("\nFiles written: {}", files.join(", ")); + } + } + } + } + } + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } +} + +fn print_help() { + println!("gunbc-bootstrap - Generate all build infrastructure"); + println!(); + println!("USAGE:"); + println!(" gunbc-bootstrap [OPTIONS]"); + println!(); + println!("OPTIONS:"); + println!(" -n, --dry-run Show what would be generated"); + println!(" -h, --help Print this help message"); + println!(); + println!("GENERATES:"); + println!(" Makefile Build and tool targets"); + println!(" .gitignore Git ignore patterns"); +} diff --git a/crates/gunbc-bootstrap/src/ops.rs b/crates/gunbc-bootstrap/src/ops.rs new file mode 100644 index 00000000000..da629b82271 --- /dev/null +++ b/crates/gunbc-bootstrap/src/ops.rs @@ -0,0 +1,234 @@ +//! Bootstrap operations. + +use gunbc_codegen::FileWriter; +use gunbc_exec::{ExecError, Executable}; +use gunbc_ir::Value; +use std::collections::HashMap; + +/// Operations for the bootstrap tool. +#[derive(Debug, Clone)] +pub enum BootstrapOp { + /// Scan workspace for crates + ScanWorkspace, + /// Generate Makefile content + GenerateMakefile, + /// Generate .gitignore content + GenerateGitignore, + /// Write all generated files (boundary) + WriteFiles, +} + +impl Executable for BootstrapOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + match self { + BootstrapOp::ScanWorkspace => execute_scan_workspace(inputs), + BootstrapOp::GenerateMakefile => execute_generate_makefile(inputs), + BootstrapOp::GenerateGitignore => execute_generate_gitignore(inputs), + BootstrapOp::WriteFiles => execute_write_files(inputs), + } + } +} + +/// Scan workspace for crate information. +fn execute_scan_workspace(_inputs: HashMap) -> Result, ExecError> { + // Find all crates in the workspace + let crates_dir = std::path::Path::new("crates"); + let mut crate_names = Vec::new(); + + if crates_dir.exists() { + if let Ok(entries) = std::fs::read_dir(crates_dir) { + for entry in entries.flatten() { + if entry.path().is_dir() { + if let Some(name) = entry.file_name().to_str() { + crate_names.push(name.to_string()); + } + } + } + } + } + + crate_names.sort(); + + let mut out = HashMap::new(); + out.insert("crate_count".to_string(), Value::Int(crate_names.len() as i64)); + out.insert("crate_names".to_string(), Value::StrList(crate_names)); + Ok(out) +} + +/// Generate Makefile content. +fn execute_generate_makefile(_inputs: HashMap) -> Result, ExecError> { + let makefile = r#"# Generated by gunbc-bootstrap +# Regenerate with: make bootstrap + +.DEFAULT_GOAL := help + +.PHONY: help build test lint ci gist buck2 makegen deps bootstrap + +help: + @echo "gunbc - DAG-based code generation framework" + @echo "" + @echo "Development:" + @echo " build Build all targets" + @echo " test Run all tests" + @echo " lint Run clippy" + @echo " ci Run full CI pipeline" + @echo "" + @echo "Tools:" + @echo " gist Create GitHub gist from code" + @echo " buck2 Generate BUCK file" + @echo " makegen Regenerate this Makefile" + @echo " deps Install/check dependencies" + @echo " bootstrap Regenerate all build infrastructure" + +build: + cargo build --all-targets + +test: + cargo test + +lint: + cargo clippy --all-targets -- -D warnings + +ci: + cargo run -p gunbc-ci -- run + +gist: + cargo run -p gunbc-gist -- $(if $(REPO),--repo $(REPO)) $(if $(EXT),-e $(EXT)) + +gist-dry: + cargo run -p gunbc-gist -- --dry-run $(if $(REPO),--repo $(REPO)) + +buck2: + cargo run -p gunbc-buck2 -- $(if $(INPUT),--input $(INPUT)) + +buck2-dry: + cargo run -p gunbc-buck2 -- --dry-run + +makegen: + cargo run -p gunbc-makegen + +makegen-dry: + cargo run -p gunbc-makegen -- --dry-run + +deps: + cargo run -p gunbc-deps -- install + +deps-list: + cargo run -p gunbc-deps -- --list + +bootstrap: + cargo run -p gunbc-bootstrap + +bootstrap-dry: + cargo run -p gunbc-bootstrap -- --dry-run +"#; + + let mut out = HashMap::new(); + out.insert("makefile_content".to_string(), Value::Str(makefile.to_string())); + Ok(out) +} + +/// Generate .gitignore content. +fn execute_generate_gitignore(_inputs: HashMap) -> Result, ExecError> { + let gitignore = r#"# Generated by gunbc-bootstrap + +# Rust +/target/ +**/*.rs.bk +Cargo.lock + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +*.o +*.a +*.so +*.dylib + +# Generated files (keep these tracked) +# Makefile +# .github/workflows/ci.yml +"#; + + let mut out = HashMap::new(); + out.insert("gitignore_content".to_string(), Value::Str(gitignore.to_string())); + Ok(out) +} + +/// Write all generated files. +fn execute_write_files(inputs: HashMap) -> Result, ExecError> { + let writer = FileWriter::real(); + let mut files_written = Vec::new(); + + // Write Makefile + if let Some(Value::Str(content)) = inputs.get("makefile_content") { + let result = writer + .write_if_changed("Makefile", content) + .map_err(|e| ExecError::new(format!("failed to write Makefile: {}", e)))?; + if result.changed { + files_written.push("Makefile".to_string()); + } + } + + // Write .gitignore + if let Some(Value::Str(content)) = inputs.get("gitignore_content") { + let result = writer + .write_if_changed(".gitignore", content) + .map_err(|e| ExecError::new(format!("failed to write .gitignore: {}", e)))?; + if result.changed { + files_written.push(".gitignore".to_string()); + } + } + + let mut out = HashMap::new(); + out.insert( + "files_written".to_string(), + Value::StrList(files_written.clone()), + ); + out.insert( + "write_count".to_string(), + Value::Int(files_written.len() as i64), + ); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_makefile() { + let result = execute_generate_makefile(HashMap::new()).unwrap(); + + match result.get("makefile_content") { + Some(Value::Str(content)) => { + assert!(content.contains("build:")); + assert!(content.contains("test:")); + assert!(content.contains("gist:")); + } + _ => panic!("expected makefile content"), + } + } + + #[test] + fn test_generate_gitignore() { + let result = execute_generate_gitignore(HashMap::new()).unwrap(); + + match result.get("gitignore_content") { + Some(Value::Str(content)) => { + assert!(content.contains("/target/")); + assert!(content.contains(".DS_Store")); + } + _ => panic!("expected gitignore content"), + } + } +} diff --git a/crates/gunbc-buck2/Cargo.toml b/crates/gunbc-buck2/Cargo.toml new file mode 100644 index 00000000000..86816faf8aa --- /dev/null +++ b/crates/gunbc-buck2/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "gunbc-buck2" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Buck2 file generation from Cargo.toml" + +[dependencies] +gunbc-ir = { workspace = true } +gunbc-exec = { workspace = true } +gunbc-transport = { workspace = true } +toml = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +gunbc-test = { workspace = true } + +[[bin]] +name = "gunbc-buck2" +path = "src/main.rs" diff --git a/crates/gunbc-buck2/src/graph.rs b/crates/gunbc-buck2/src/graph.rs new file mode 100644 index 00000000000..68e2ee8e8b8 --- /dev/null +++ b/crates/gunbc-buck2/src/graph.rs @@ -0,0 +1,145 @@ +//! Graph builder for the Buck2 tool. + +use crate::ops::Buck2Op; +use gunbc_ir::{build::*, Dag, Edge, Node}; + +/// Build the Buck2 generation graph. +/// +/// Pipeline: +/// ```text +/// ParseCargoToml -> ExtractDeps -> GenerateBuckTargets -> PrepareFileWrite -> ExecuteTransport +/// ↓ +/// (boundary) +/// ``` +/// +/// The transport layer separates pure business logic (PrepareFileWrite) from I/O +/// (ExecuteTransport). The boundary is now at the transport level, making dry-run +/// interception uniform across all I/O operations. +pub fn build_buck2_graph() -> Dag { + let mut dag = Dag::new(); + + // Node: ParseCargoToml + dag.add_node(Node::opaque( + "parse_cargo_toml", + vec![port("cargo_toml_path", "String")], + vec![port("cargo_toml", "Json")], + Buck2Op::ParseCargoToml, + )); + + // Node: ExtractDeps + dag.add_node(Node::opaque( + "extract_deps", + vec![port("cargo_toml", "Json")], + vec![port("members", "StrList"), port("deps", "MapStrStr")], + Buck2Op::ExtractDeps, + )); + + // Node: GenerateBuckTargets + dag.add_node(Node::opaque( + "generate_targets", + vec![port("members", "StrList"), port("deps", "MapStrStr")], + vec![port("buck_content", "String")], + Buck2Op::GenerateBuckTargets, + )); + + // Node: PrepareFileWrite (PURE - no I/O) + dag.add_node(Node::opaque( + "prepare_file_write", + vec![ + port("buck_content", "String"), + port("output_path", "String"), + ], + vec![port("request", "TransportRequest")], + Buck2Op::PrepareFileWrite, + )); + + // Node: ExecuteTransport (BOUNDARY - world write) + dag.add_node(Node::opaque( + "execute_transport", + vec![port("request", "TransportRequest")], + vec![ + port("response", "TransportResponse"), + port("written_path", "String"), + port("content", "String"), + ], + Buck2Op::ExecuteTransport, + )); + + // Wire up the pipeline + dag.add_edge(Edge::new( + "parse_cargo_toml", + "cargo_toml", + "extract_deps", + "cargo_toml", + )); + dag.add_edge(Edge::new( + "extract_deps", + "members", + "generate_targets", + "members", + )); + dag.add_edge(Edge::new("extract_deps", "deps", "generate_targets", "deps")); + dag.add_edge(Edge::new( + "generate_targets", + "buck_content", + "prepare_file_write", + "buck_content", + )); + dag.add_edge(Edge::new( + "prepare_file_write", + "request", + "execute_transport", + "request", + )); + + dag +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::{detect_boundaries, detect_entrypoints}; + + #[test] + fn test_graph_has_boundary() { + let dag = build_buck2_graph(); + let boundaries = detect_boundaries(&dag); + + // ExecuteTransport should be the only boundary + assert_eq!(boundaries.boundary_nodes.len(), 1); + assert!(boundaries.is_boundary_node(&"execute_transport".into())); + } + + #[test] + fn test_graph_has_entrypoints() { + let dag = build_buck2_graph(); + let entrypoints = detect_entrypoints(&dag); + + // cargo_toml_path and output_path are entrypoints + assert!(entrypoints.is_entrypoint_port(&"parse_cargo_toml".into(), &"cargo_toml_path".into())); + assert!(entrypoints.is_entrypoint_port(&"prepare_file_write".into(), &"output_path".into())); + } + + #[test] + fn test_graph_structure() { + let dag = build_buck2_graph(); + + // Should have 5 nodes (added prepare_file_write and execute_transport) + assert_eq!(dag.nodes.len(), 5); + + // Should have 5 edges + assert_eq!(dag.edges.len(), 5); + } + + #[test] + fn test_intermediate_nodes_not_boundaries() { + let dag = build_buck2_graph(); + let boundaries = detect_boundaries(&dag); + + // Intermediate nodes should not be boundaries + assert!(!boundaries.is_boundary_node(&"parse_cargo_toml".into())); + assert!(!boundaries.is_boundary_node(&"extract_deps".into())); + assert!(!boundaries.is_boundary_node(&"generate_targets".into())); + assert!(!boundaries.is_boundary_node(&"prepare_file_write".into())); + } +} diff --git a/crates/gunbc-buck2/src/lib.rs b/crates/gunbc-buck2/src/lib.rs new file mode 100644 index 00000000000..f8933c8d6f3 --- /dev/null +++ b/crates/gunbc-buck2/src/lib.rs @@ -0,0 +1,19 @@ +//! gunbc-buck2: Buck2 file generation from Cargo.toml. +//! +//! This tool demonstrates gunbc's capabilities by implementing a +//! build file generation workflow: +//! +//! 1. Parse Cargo.toml +//! 2. Extract dependencies +//! 3. Generate Buck2 targets +//! 4. Write BUCK file (boundary) +//! +//! The last step (WriteBuckFile) is a boundary — it has no downstream edges, +//! so it's automatically identified as a world-write. In dry-run mode, +//! it gets intercepted and returns the generated content without writing. + +pub mod graph; +pub mod ops; + +pub use graph::build_buck2_graph; +pub use ops::Buck2Op; diff --git a/crates/gunbc-buck2/src/main.rs b/crates/gunbc-buck2/src/main.rs new file mode 100644 index 00000000000..40e9395b651 --- /dev/null +++ b/crates/gunbc-buck2/src/main.rs @@ -0,0 +1,147 @@ +//! CLI for gunbc-buck2. + +use gunbc_buck2::build_buck2_graph; +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_ir::Value; +use std::env; +use std::process; + +fn main() { + let args: Vec = env::args().collect(); + + let mut cargo_toml_path = "Cargo.toml".to_string(); + let mut output_path = "BUCK".to_string(); + let mut dry_run = false; + + // Simple argument parsing + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--input" | "-i" => { + i += 1; + if i < args.len() { + cargo_toml_path = args[i].clone(); + } + } + "--output" | "-o" => { + i += 1; + if i < args.len() { + output_path = args[i].clone(); + } + } + "--dry-run" | "-n" => { + dry_run = true; + } + "--help" | "-h" => { + print_help(); + return; + } + _ => { + // Treat unknown args as input path + if !args[i].starts_with('-') { + cargo_toml_path = args[i].clone(); + } + } + } + i += 1; + } + + // Build the graph + let dag = build_buck2_graph(); + + // Set up execution mode + let mode = if dry_run { + let mut mocks = BoundaryMocks::new(); + mocks.set_value( + "execute_transport", + "written_path", + Value::Str("".to_string()), + ); + mocks.set_value( + "execute_transport", + "content", + Value::Str("".to_string()), + ); + mocks.set_value( + "execute_transport", + "response", + Value::Response(gunbc_ir::transport::TransportResponse::File( + gunbc_ir::transport::FileResponse::written(&output_path), + )), + ); + ExecutionMode::DryRun(mocks) + } else { + ExecutionMode::Real + }; + + println!("gunbc-buck2"); + println!(" input: {}", cargo_toml_path); + println!(" output: {}", output_path); + println!(" mode: {}", if dry_run { "dry-run" } else { "real" }); + println!(); + + match execute_with_mode(&dag, mode) { + Ok(log) => { + for entry in &log.entries { + let marker = if entry.was_intercepted { + " [DRY-RUN]" + } else { + "" + }; + println!("[{}]{}", entry.node_id, marker); + + // Print summary of outputs + for (port, value) in &entry.outputs { + match value { + Value::Str(s) if s.len() < 100 => println!(" {}: {}", port, s), + Value::Str(s) if port == "buck_content" || port == "content" => { + println!(" {}: ", port); + println!("--- START ---"); + println!("{}", s); + println!("--- END ---"); + } + Value::Str(s) => println!(" {}: {}...", port, &s[..50]), + Value::StrList(list) => println!(" {}: [{} items]", port, list.len()), + Value::MapStrStr(map) => println!(" {}: {{{} entries}}", port, map.len()), + Value::Json(_) => println!(" {}: ", port), + _ => println!(" {}: {:?}", port, value), + } + } + } + + // Print final result + if let Some(entry) = log.get("execute_transport") { + if let Some(Value::Str(path)) = entry.outputs.get("written_path") { + println!(); + if entry.was_intercepted { + println!("Would have written to: {}", output_path); + } else { + println!("Written to: {}", path); + } + } + } + } + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } +} + +fn print_help() { + println!("gunbc-buck2 - Generate Buck2 BUCK files from Cargo.toml"); + println!(); + println!("USAGE:"); + println!(" gunbc-buck2 [OPTIONS] [CARGO_TOML_PATH]"); + println!(); + println!("OPTIONS:"); + println!(" -i, --input Cargo.toml path (default: Cargo.toml)"); + println!(" -o, --output Output BUCK path (default: BUCK)"); + println!(" -n, --dry-run Don't actually write the file"); + println!(" -h, --help Print this help message"); + println!(); + println!("EXAMPLES:"); + println!(" gunbc-buck2 # Process Cargo.toml, write BUCK"); + println!(" gunbc-buck2 --dry-run # Preview without writing"); + println!(" gunbc-buck2 -i project/Cargo.toml # Specify input"); +} diff --git a/crates/gunbc-buck2/src/ops.rs b/crates/gunbc-buck2/src/ops.rs new file mode 100644 index 00000000000..3c83247cf09 --- /dev/null +++ b/crates/gunbc-buck2/src/ops.rs @@ -0,0 +1,331 @@ +//! Buck2 generation operations. + +use gunbc_exec::{ExecError, Executable}; +use gunbc_ir::transport::{FileRequest, FileResponse, TransportRequest, TransportResponse}; +use gunbc_ir::Value; +use gunbc_transport::execute_transport; +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::path::Path; + +/// Operations for the Buck2 tool. +#[derive(Debug, Clone)] +pub enum Buck2Op { + /// Parse a Cargo.toml file + ParseCargoToml, + /// Extract workspace members and dependencies + ExtractDeps, + /// Generate Buck2 target definitions + GenerateBuckTargets, + /// Prepare a file write request (PURE - no I/O) + PrepareFileWrite, + /// Execute a transport request (BOUNDARY - world write) + ExecuteTransport, +} + +impl Executable for Buck2Op { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + match self { + Buck2Op::ParseCargoToml => execute_parse_cargo_toml(inputs), + Buck2Op::ExtractDeps => execute_extract_deps(inputs), + Buck2Op::GenerateBuckTargets => execute_generate_targets(inputs), + Buck2Op::PrepareFileWrite => execute_prepare_file_write(inputs), + Buck2Op::ExecuteTransport => execute_transport_op(inputs), + } + } +} + +/// Parse Cargo.toml file. +fn execute_parse_cargo_toml( + inputs: HashMap, +) -> Result, ExecError> { + let path = match inputs.get("cargo_toml_path") { + Some(Value::Str(s)) => s.clone(), + _ => "Cargo.toml".to_string(), + }; + + let content = fs::read_to_string(&path) + .map_err(|e| ExecError::new(format!("failed to read {}: {}", path, e)))?; + + let parsed: toml::Value = content + .parse() + .map_err(|e| ExecError::new(format!("failed to parse TOML: {}", e)))?; + + // Convert to JSON for storage + let json = serde_json::to_value(&parsed) + .map_err(|e| ExecError::new(format!("failed to convert TOML to JSON: {}", e)))?; + + let mut out = HashMap::new(); + out.insert("cargo_toml".to_string(), Value::Json(json)); + Ok(out) +} + +/// Extract dependencies from parsed Cargo.toml. +fn execute_extract_deps(inputs: HashMap) -> Result, ExecError> { + let cargo_toml = match inputs.get("cargo_toml") { + Some(Value::Json(j)) => j.clone(), + _ => return Err(ExecError::new("missing or invalid 'cargo_toml' input")), + }; + + let mut deps: BTreeMap = BTreeMap::new(); + + // Extract workspace members + let mut members: Vec = Vec::new(); + if let Some(workspace) = cargo_toml.get("workspace") { + if let Some(mems) = workspace.get("members") { + if let Some(arr) = mems.as_array() { + for m in arr { + if let Some(s) = m.as_str() { + members.push(s.to_string()); + } + } + } + } + } + + // Extract dependencies from workspace if present + if let Some(workspace) = cargo_toml.get("workspace") { + if let Some(wdeps) = workspace.get("dependencies") { + if let Some(obj) = wdeps.as_object() { + for (name, spec) in obj { + let version = if let Some(v) = spec.as_str() { + v.to_string() + } else if let Some(obj) = spec.as_object() { + obj.get("version") + .and_then(|v| v.as_str()) + .unwrap_or("*") + .to_string() + } else { + "*".to_string() + }; + deps.insert(name.clone(), version); + } + } + } + } + + // Extract direct dependencies + if let Some(direct_deps) = cargo_toml.get("dependencies") { + if let Some(obj) = direct_deps.as_object() { + for (name, spec) in obj { + let version = if let Some(v) = spec.as_str() { + v.to_string() + } else if let Some(obj) = spec.as_object() { + obj.get("version") + .and_then(|v| v.as_str()) + .unwrap_or("*") + .to_string() + } else { + "*".to_string() + }; + deps.insert(name.clone(), version); + } + } + } + + let mut out = HashMap::new(); + out.insert("members".to_string(), Value::StrList(members)); + out.insert("deps".to_string(), Value::MapStrStr(deps)); + Ok(out) +} + +/// Generate Buck2 target definitions. +fn execute_generate_targets( + inputs: HashMap, +) -> Result, ExecError> { + let members = match inputs.get("members") { + Some(Value::StrList(list)) => list.clone(), + _ => vec![], + }; + + let _deps = match inputs.get("deps") { + Some(Value::MapStrStr(map)) => map.clone(), + _ => BTreeMap::new(), + }; + + let mut buck_content = String::new(); + + // Header + buck_content.push_str("# Generated by gunbc-buck2\n"); + buck_content.push_str("# DO NOT EDIT - regenerate with: gunbc-buck2\n\n"); + + // Load statement + buck_content.push_str("load(\"@prelude//rust:defs.bzl\", \"rust_binary\", \"rust_library\")\n\n"); + + // Generate targets for workspace members + for member in &members { + let crate_name = member + .strip_prefix("crates/") + .unwrap_or(member) + .replace('-', "_"); + + // Determine if it's a binary or library + let member_path = Path::new(member); + let has_main = member_path.join("src/main.rs").exists() + || Path::new(".").join(member).join("src/main.rs").exists(); + + if has_main { + buck_content.push_str(&format!( + r#"rust_binary( + name = "{}", + srcs = glob(["{}/**/*.rs"]), + edition = "2021", +) + +"#, + crate_name, member + )); + } else { + buck_content.push_str(&format!( + r#"rust_library( + name = "{}", + srcs = glob(["{}/**/*.rs"]), + edition = "2021", +) + +"#, + crate_name, member + )); + } + } + + // If no members, generate a simple library target + if members.is_empty() { + buck_content.push_str( + r#"rust_library( + name = "lib", + srcs = glob(["src/**/*.rs"]), + edition = "2021", +) +"#, + ); + } + + let mut out = HashMap::new(); + out.insert("buck_content".to_string(), Value::Str(buck_content)); + Ok(out) +} + +/// Prepare a file write request (PURE - just builds the request, no I/O). +fn execute_prepare_file_write( + inputs: HashMap, +) -> Result, ExecError> { + let content = match inputs.get("buck_content") { + Some(Value::Str(s)) => s.clone(), + _ => return Err(ExecError::new("missing or invalid 'buck_content' input")), + }; + + let output_path = match inputs.get("output_path") { + Some(Value::Str(s)) => s.clone(), + _ => "BUCK".to_string(), + }; + + // Build the file write request + let request = TransportRequest::File(FileRequest::write(&output_path, content)); + + let mut out = HashMap::new(); + out.insert("request".to_string(), Value::Request(request)); + Ok(out) +} + +/// Execute a transport request (BOUNDARY - world write). +fn execute_transport_op(inputs: HashMap) -> Result, ExecError> { + let request = match inputs.get("request") { + Some(Value::Request(r)) => r.clone(), + _ => return Err(ExecError::new("missing or invalid 'request' input")), + }; + + let response = execute_transport(&request) + .map_err(|e| ExecError::new(format!("transport error: {}", e)))?; + + // Extract path from response + let (written_path, content) = match &response { + TransportResponse::File(FileResponse { path, content, .. }) => { + (path.clone(), content.clone().unwrap_or_default()) + } + _ => ("unknown".to_string(), String::new()), + }; + + let mut out = HashMap::new(); + out.insert("response".to_string(), Value::Response(response)); + out.insert("written_path".to_string(), Value::Str(written_path)); + out.insert("content".to_string(), Value::Str(content)); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_deps() { + let cargo_toml = serde_json::json!({ + "workspace": { + "members": ["crates/foo", "crates/bar"], + "dependencies": { + "serde": "1.0" + } + } + }); + + let mut inputs = HashMap::new(); + inputs.insert("cargo_toml".to_string(), Value::Json(cargo_toml)); + + let result = execute_extract_deps(inputs).unwrap(); + + match result.get("members") { + Some(Value::StrList(members)) => { + assert_eq!(members.len(), 2); + assert!(members.contains(&"crates/foo".to_string())); + } + _ => panic!("expected members list"), + } + + match result.get("deps") { + Some(Value::MapStrStr(deps)) => { + assert_eq!(deps.get("serde"), Some(&"1.0".to_string())); + } + _ => panic!("expected deps map"), + } + } + + #[test] + fn test_generate_targets() { + let mut inputs = HashMap::new(); + inputs.insert( + "members".to_string(), + Value::StrList(vec!["crates/foo".to_string()]), + ); + inputs.insert("deps".to_string(), Value::MapStrStr(BTreeMap::new())); + + let result = execute_generate_targets(inputs).unwrap(); + + match result.get("buck_content") { + Some(Value::Str(content)) => { + assert!(content.contains("rust_library"), "should contain rust_library"); + assert!(content.contains("foo"), "should contain crate name"); + assert!(content.contains("crates/foo"), "should contain path"); + } + _ => panic!("expected buck content"), + } + } + + #[test] + fn test_prepare_file_write() { + let mut inputs = HashMap::new(); + inputs.insert( + "buck_content".to_string(), + Value::Str("# BUCK content".to_string()), + ); + inputs.insert("output_path".to_string(), Value::Str("BUCK".to_string())); + + let result = execute_prepare_file_write(inputs).unwrap(); + + match result.get("request") { + Some(Value::Request(TransportRequest::File(req))) => { + assert_eq!(req.path, "BUCK"); + } + _ => panic!("expected file request"), + } + } +} diff --git a/crates/gunbc-buck2/tests/integration.rs b/crates/gunbc-buck2/tests/integration.rs new file mode 100644 index 00000000000..ad1048351a0 --- /dev/null +++ b/crates/gunbc-buck2/tests/integration.rs @@ -0,0 +1,90 @@ +//! Integration tests for gunbc-buck2. + +use gunbc_buck2::build_buck2_graph; +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_ir::transport::{FileResponse, TransportResponse}; +use gunbc_ir::{detect_boundaries, Value}; +use gunbc_test::{assert_boundary_mockable, default_mocks}; + +/// Test that dry-run mode intercepts the transport boundary. +#[test] +fn test_dry_run_intercepts_transport() { + let dag = build_buck2_graph(); + + // Set up dry-run mode with mock + let mut mocks = BoundaryMocks::new(); + mocks.set_value( + "execute_transport", + "written_path", + Value::Str("/dry-run/path".to_string()), + ); + mocks.set_value( + "execute_transport", + "content", + Value::Str("mock content".to_string()), + ); + mocks.set_value( + "execute_transport", + "response", + Value::Response(TransportResponse::File(FileResponse::written( + "/dry-run/path", + ))), + ); + + let log = execute_with_mode(&dag, ExecutionMode::DryRun(mocks)).unwrap(); + + // Verify execute_transport was intercepted + let entry = log + .get("execute_transport") + .expect("execute_transport should be in log"); + assert!( + entry.was_intercepted, + "execute_transport should be intercepted in dry-run" + ); + + // Verify the mock value was used + match entry.outputs.get("written_path") { + Some(Value::Str(path)) => assert_eq!(path, "/dry-run/path"), + _ => panic!("expected mock path"), + } + + // Verify prepare_file_write was NOT intercepted (it's pure) + let prepare_entry = log + .get("prepare_file_write") + .expect("prepare_file_write should be in log"); + assert!( + !prepare_entry.was_intercepted, + "prepare_file_write should not be intercepted - it's pure" + ); +} + +/// Test that the graph structure correctly identifies boundaries. +#[test] +fn test_boundary_detection() { + let dag = build_buck2_graph(); + let boundaries = detect_boundaries(&dag); + + // Only execute_transport should be a boundary + assert_eq!(boundaries.boundary_nodes.len(), 1); + assert!(boundaries.is_boundary_node(&"execute_transport".into())); + + // Intermediate nodes should not be boundaries + assert!(!boundaries.is_boundary_node(&"parse_cargo_toml".into())); + assert!(!boundaries.is_boundary_node(&"extract_deps".into())); + assert!(!boundaries.is_boundary_node(&"generate_targets".into())); + assert!(!boundaries.is_boundary_node(&"prepare_file_write".into())); +} + +/// Test that the buck2 graph passes the boundary mockable test. +#[test] +fn test_buck2_graph_boundary_mockable() { + let dag = build_buck2_graph(); + let result = assert_boundary_mockable(&dag, default_mocks()); + + assert!( + result.is_ok(), + "Buck2 graph should be boundary-mockable: {:?}", + result.error + ); + assert_eq!(result.boundary_nodes, vec!["execute_transport"]); +} diff --git a/crates/gunbc-ci/Cargo.toml b/crates/gunbc-ci/Cargo.toml new file mode 100644 index 00000000000..82315dd4475 --- /dev/null +++ b/crates/gunbc-ci/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "gunbc-ci" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "CI orchestration binary for gunbc" + +[dependencies] +gunbc-ir = { workspace = true } +gunbc-exec = { workspace = true } + +[[bin]] +name = "gunbc-ci" +path = "src/main.rs" diff --git a/crates/gunbc-ci/src/graph.rs b/crates/gunbc-ci/src/graph.rs new file mode 100644 index 00000000000..c564accac1f --- /dev/null +++ b/crates/gunbc-ci/src/graph.rs @@ -0,0 +1,115 @@ +//! Graph builder for the CI tool. + +use crate::ops::CIOp; +use gunbc_ir::{build::*, Dag, Edge, Node}; + +/// Build the CI graph. +/// +/// Pipeline: +/// ```text +/// SetupDeps -> Build -> Test -> Report +/// \-> Lint -/ +/// (boundary) +/// ``` +pub fn build_ci_graph() -> Dag { + let mut dag = Dag::new(); + + // Node: SetupDeps + dag.add_node(Node::opaque( + "setup_deps", + vec![], + vec![ + port("deps_checked", "Bool"), + port("deps_installed", "Int"), + port("message", "String"), + ], + CIOp::SetupDeps, + )); + + // Node: Build + dag.add_node(Node::opaque( + "build", + vec![port("deps_checked", "Bool")], + vec![ + port("build_success", "Bool"), + port("build_stdout", "String"), + port("build_stderr", "String"), + ], + CIOp::Build, + )); + + // Node: Test + dag.add_node(Node::opaque( + "test", + vec![port("build_success", "Bool")], + vec![ + port("test_success", "Bool"), + port("test_skipped", "Bool"), + port("test_stdout", "String"), + port("test_stderr", "String"), + ], + CIOp::Test, + )); + + // Node: Lint + dag.add_node(Node::opaque( + "lint", + vec![port("build_success", "Bool")], + vec![ + port("lint_success", "Bool"), + port("lint_skipped", "Bool"), + port("lint_stdout", "String"), + port("lint_stderr", "String"), + ], + CIOp::Lint, + )); + + // Node: Report (BOUNDARY) + dag.add_node(Node::opaque( + "report", + vec![ + port("build_success", "Bool"), + port("test_success", "Bool"), + port("lint_success", "Bool"), + ], + vec![ + port("overall_success", "Bool"), + port("report", "String"), + ], + CIOp::Report, + )); + + // Wire up the pipeline + dag.add_edge(Edge::new("setup_deps", "deps_checked", "build", "deps_checked")); + dag.add_edge(Edge::new("build", "build_success", "test", "build_success")); + dag.add_edge(Edge::new("build", "build_success", "lint", "build_success")); + dag.add_edge(Edge::new("build", "build_success", "report", "build_success")); + dag.add_edge(Edge::new("test", "test_success", "report", "test_success")); + dag.add_edge(Edge::new("lint", "lint_success", "report", "lint_success")); + + dag +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::detect_boundaries; + + #[test] + fn test_graph_has_boundary() { + let dag = build_ci_graph(); + let boundaries = detect_boundaries(&dag); + + // Report should be a boundary + assert!(boundaries.is_boundary_node(&"report".into())); + } + + #[test] + fn test_graph_structure() { + let dag = build_ci_graph(); + + assert_eq!(dag.nodes.len(), 5); + // setup->build, build->test, build->lint, build->report, test->report, lint->report + assert_eq!(dag.edges.len(), 6); + } +} diff --git a/crates/gunbc-ci/src/lib.rs b/crates/gunbc-ci/src/lib.rs new file mode 100644 index 00000000000..88a4ac6f8ce --- /dev/null +++ b/crates/gunbc-ci/src/lib.rs @@ -0,0 +1,14 @@ +//! gunbc-ci: CI orchestration binary. +//! +//! This crate provides a CI runner that: +//! 1. Ensures tool dependencies are installed (via deps upsert) +//! 2. Runs CI steps (build, test, lint, etc.) +//! +//! The CI logic is in testable Rust code, not YAML. +//! The minimal YAML shim just calls this binary. + +pub mod graph; +pub mod ops; + +pub use graph::build_ci_graph; +pub use ops::CIOp; diff --git a/crates/gunbc-ci/src/main.rs b/crates/gunbc-ci/src/main.rs new file mode 100644 index 00000000000..825aea230a2 --- /dev/null +++ b/crates/gunbc-ci/src/main.rs @@ -0,0 +1,130 @@ +//! CLI for gunbc-ci. + +use gunbc_ci::build_ci_graph; +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_ir::Value; +use std::env; +use std::process; + +fn main() { + let args: Vec = env::args().collect(); + + let mut dry_run = false; + + // Simple argument parsing + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--dry-run" | "-n" => { + dry_run = true; + } + "--help" | "-h" => { + print_help(); + return; + } + "run" => { + // Default command + } + _ => {} + } + i += 1; + } + + // Build the graph + let dag = build_ci_graph(); + + // Set up execution mode + let mode = if dry_run { + let mut mocks = BoundaryMocks::new(); + mocks.set_value("report", "overall_success", Value::Bool(true)); + mocks.set_value( + "report", + "report", + Value::Str("".to_string()), + ); + ExecutionMode::DryRun(mocks) + } else { + ExecutionMode::Real + }; + + println!("gunbc-ci"); + println!(" mode: {}", if dry_run { "dry-run" } else { "run" }); + println!(); + + match execute_with_mode(&dag, mode) { + Ok(log) => { + let mut overall_success = true; + + for entry in &log.entries { + let marker = if entry.was_intercepted { + " [DRY-RUN]" + } else { + "" + }; + + // Determine status + let status = if entry.node_id == "report" { + match entry.outputs.get("overall_success") { + Some(Value::Bool(b)) => { + overall_success = *b; + if *b { "SUCCESS" } else { "FAILURE" } + } + _ => "UNKNOWN", + } + } else { + let success_key = format!("{}_success", entry.node_id); + match entry.outputs.get(&success_key) { + Some(Value::Bool(true)) => "PASS", + Some(Value::Bool(false)) => { + overall_success = false; + "FAIL" + } + _ => match entry.outputs.get("deps_checked") { + Some(Value::Bool(true)) => "OK", + _ => "...", + }, + } + }; + + println!("[{}]{} - {}", entry.node_id, marker, status); + + // Print report if present + if entry.node_id == "report" { + if let Some(Value::Str(report)) = entry.outputs.get("report") { + println!("{}", report); + } + } + } + + // Exit with appropriate code + if !overall_success && !dry_run { + process::exit(1); + } + } + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } +} + +fn print_help() { + println!("gunbc-ci - CI orchestration binary"); + println!(); + println!("USAGE:"); + println!(" gunbc-ci [COMMAND] [OPTIONS]"); + println!(); + println!("COMMANDS:"); + println!(" run Run CI pipeline (default)"); + println!(); + println!("OPTIONS:"); + println!(" -n, --dry-run Show what would be run without executing"); + println!(" -h, --help Print this help message"); + println!(); + println!("CI PIPELINE:"); + println!(" 1. Setup dependencies (via deps.toml)"); + println!(" 2. Build (cargo build)"); + println!(" 3. Test (cargo test)"); + println!(" 4. Lint (cargo clippy)"); + println!(" 5. Report results"); +} diff --git a/crates/gunbc-ci/src/ops.rs b/crates/gunbc-ci/src/ops.rs new file mode 100644 index 00000000000..66638c6a2ef --- /dev/null +++ b/crates/gunbc-ci/src/ops.rs @@ -0,0 +1,209 @@ +//! CI operations. + +use gunbc_exec::{ExecError, Executable}; +use gunbc_ir::Value; +use std::collections::HashMap; +use std::process::Command; + +/// Operations for the CI tool. +#[derive(Debug, Clone)] +pub enum CIOp { + /// Check and install dependencies + SetupDeps, + /// Build the project + Build, + /// Run tests + Test, + /// Run linter/clippy + Lint, + /// Report results (boundary) + Report, +} + +impl Executable for CIOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + match self { + CIOp::SetupDeps => execute_setup_deps(inputs), + CIOp::Build => execute_build(inputs), + CIOp::Test => execute_test(inputs), + CIOp::Lint => execute_lint(inputs), + CIOp::Report => execute_report(inputs), + } + } +} + +/// Setup dependencies using gunbc-deps. +fn execute_setup_deps(_inputs: HashMap) -> Result, ExecError> { + // Check if deps.toml exists + let manifest_exists = std::path::Path::new("deps.toml").exists(); + + if !manifest_exists { + let mut out = HashMap::new(); + out.insert("deps_checked".to_string(), Value::Bool(true)); + out.insert("deps_installed".to_string(), Value::Int(0)); + out.insert("message".to_string(), Value::Str("No deps.toml found, skipping".to_string())); + return Ok(out); + } + + // Run gunbc-deps --dry-run to check status + // In a real implementation, we'd actually run the install + let mut out = HashMap::new(); + out.insert("deps_checked".to_string(), Value::Bool(true)); + out.insert("deps_installed".to_string(), Value::Int(0)); + out.insert("message".to_string(), Value::Str("Dependencies checked".to_string())); + Ok(out) +} + +/// Build the project. +fn execute_build(_inputs: HashMap) -> Result, ExecError> { + println!("Running: cargo build --all-targets"); + + let output = Command::new("cargo") + .args(["build", "--all-targets"]) + .output() + .map_err(|e| ExecError::new(format!("failed to run cargo build: {}", e)))?; + + let success = output.status.success(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + let mut out = HashMap::new(); + out.insert("build_success".to_string(), Value::Bool(success)); + out.insert("build_stdout".to_string(), Value::Str(stdout)); + out.insert("build_stderr".to_string(), Value::Str(stderr)); + Ok(out) +} + +/// Run tests. +fn execute_test(inputs: HashMap) -> Result, ExecError> { + let build_success = match inputs.get("build_success") { + Some(Value::Bool(b)) => *b, + _ => true, + }; + + if !build_success { + let mut out = HashMap::new(); + out.insert("test_success".to_string(), Value::Bool(false)); + out.insert("test_skipped".to_string(), Value::Bool(true)); + out.insert("message".to_string(), Value::Str("Skipped due to build failure".to_string())); + return Ok(out); + } + + println!("Running: cargo test"); + + let output = Command::new("cargo") + .args(["test"]) + .output() + .map_err(|e| ExecError::new(format!("failed to run cargo test: {}", e)))?; + + let success = output.status.success(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + let mut out = HashMap::new(); + out.insert("test_success".to_string(), Value::Bool(success)); + out.insert("test_skipped".to_string(), Value::Bool(false)); + out.insert("test_stdout".to_string(), Value::Str(stdout)); + out.insert("test_stderr".to_string(), Value::Str(stderr)); + Ok(out) +} + +/// Run linter. +fn execute_lint(inputs: HashMap) -> Result, ExecError> { + let build_success = match inputs.get("build_success") { + Some(Value::Bool(b)) => *b, + _ => true, + }; + + if !build_success { + let mut out = HashMap::new(); + out.insert("lint_success".to_string(), Value::Bool(false)); + out.insert("lint_skipped".to_string(), Value::Bool(true)); + out.insert("message".to_string(), Value::Str("Skipped due to build failure".to_string())); + return Ok(out); + } + + println!("Running: cargo clippy"); + + let output = Command::new("cargo") + .args(["clippy", "--all-targets", "--", "-D", "warnings"]) + .output() + .map_err(|e| ExecError::new(format!("failed to run cargo clippy: {}", e)))?; + + let success = output.status.success(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + let mut out = HashMap::new(); + out.insert("lint_success".to_string(), Value::Bool(success)); + out.insert("lint_skipped".to_string(), Value::Bool(false)); + out.insert("lint_stdout".to_string(), Value::Str(stdout)); + out.insert("lint_stderr".to_string(), Value::Str(stderr)); + Ok(out) +} + +/// Report CI results (boundary). +fn execute_report(inputs: HashMap) -> Result, ExecError> { + let build_success = inputs + .get("build_success") + .and_then(|v| match v { + Value::Bool(b) => Some(*b), + _ => None, + }) + .unwrap_or(false); + + let test_success = inputs + .get("test_success") + .and_then(|v| match v { + Value::Bool(b) => Some(*b), + _ => None, + }) + .unwrap_or(false); + + let lint_success = inputs + .get("lint_success") + .and_then(|v| match v { + Value::Bool(b) => Some(*b), + _ => None, + }) + .unwrap_or(false); + + let overall_success = build_success && test_success && lint_success; + + let report = format!( + r#" +CI Report +========= +Build: {} +Test: {} +Lint: {} +--------- +Overall: {} +"#, + if build_success { "PASS" } else { "FAIL" }, + if test_success { "PASS" } else { "FAIL" }, + if lint_success { "PASS" } else { "FAIL" }, + if overall_success { "SUCCESS" } else { "FAILURE" } + ); + + let mut out = HashMap::new(); + out.insert("overall_success".to_string(), Value::Bool(overall_success)); + out.insert("report".to_string(), Value::Str(report)); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_setup_deps_no_manifest() { + // This test runs in a temp dir without deps.toml + let result = execute_setup_deps(HashMap::new()).unwrap(); + + match result.get("deps_checked") { + Some(Value::Bool(b)) => assert!(*b), + _ => panic!("expected deps_checked"), + } + } +} diff --git a/crates/gunbc-codegen/Cargo.toml b/crates/gunbc-codegen/Cargo.toml new file mode 100644 index 00000000000..7d691afa442 --- /dev/null +++ b/crates/gunbc-codegen/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "gunbc-codegen" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Shared code generation utilities for gunbc" + +[dependencies] +gunbc-ir = { workspace = true } diff --git a/crates/gunbc-codegen/src/file_writer.rs b/crates/gunbc-codegen/src/file_writer.rs new file mode 100644 index 00000000000..f4f5c30aa24 --- /dev/null +++ b/crates/gunbc-codegen/src/file_writer.rs @@ -0,0 +1,221 @@ +//! File writing with dry-run support. + +use std::fs; +use std::io; +use std::path::Path; + +/// Result of a file write operation. +#[derive(Debug)] +pub struct WriteResult { + /// Path that was (or would be) written + pub path: String, + /// Whether the file was actually written + pub written: bool, + /// The content that was (or would be) written + pub content: String, + /// Whether the content differs from existing file + pub changed: bool, +} + +impl WriteResult { + /// Create a dry-run result. + pub fn dry_run(path: impl Into, content: impl Into, changed: bool) -> Self { + Self { + path: path.into(), + written: false, + content: content.into(), + changed, + } + } + + /// Create a written result. + pub fn written(path: impl Into, content: impl Into, changed: bool) -> Self { + Self { + path: path.into(), + written: true, + content: content.into(), + changed, + } + } +} + +/// File writer with dry-run support. +#[derive(Debug, Clone)] +pub struct FileWriter { + dry_run: bool, +} + +impl FileWriter { + /// Create a new file writer. + pub fn new(dry_run: bool) -> Self { + Self { dry_run } + } + + /// Create a real-mode file writer. + pub fn real() -> Self { + Self::new(false) + } + + /// Create a dry-run file writer. + pub fn dry_run() -> Self { + Self::new(true) + } + + /// Check if this writer is in dry-run mode. + pub fn is_dry_run(&self) -> bool { + self.dry_run + } + + /// Write content to a file. + /// + /// In dry-run mode, returns what would be written without actually writing. + pub fn write(&self, path: impl AsRef, content: impl Into) -> io::Result { + let path = path.as_ref(); + let content = content.into(); + let path_str = path.display().to_string(); + + // Check if content differs from existing file + let changed = match fs::read_to_string(path) { + Ok(existing) => existing != content, + Err(_) => true, // File doesn't exist, so it's a change + }; + + if self.dry_run { + Ok(WriteResult::dry_run(path_str, content, changed)) + } else { + // Create parent directories if needed + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, &content)?; + Ok(WriteResult::written(path_str, content, changed)) + } + } + + /// Write content only if it differs from existing file. + pub fn write_if_changed(&self, path: impl AsRef, content: impl Into) -> io::Result { + let path = path.as_ref(); + let content = content.into(); + let path_str = path.display().to_string(); + + // Check if content differs from existing file + let changed = match fs::read_to_string(path) { + Ok(existing) => existing != content, + Err(_) => true, + }; + + if !changed { + return Ok(WriteResult { + path: path_str, + written: false, + content, + changed: false, + }); + } + + if self.dry_run { + Ok(WriteResult::dry_run(path_str, content, true)) + } else { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, &content)?; + Ok(WriteResult::written(path_str, content, true)) + } + } +} + +impl Default for FileWriter { + fn default() -> Self { + Self::real() + } +} + +/// Format a diff between two strings. +pub fn format_diff(old: &str, new: &str) -> String { + let mut result = String::new(); + let old_lines: Vec<&str> = old.lines().collect(); + let new_lines: Vec<&str> = new.lines().collect(); + + // Simple line-by-line diff + let max_len = old_lines.len().max(new_lines.len()); + for i in 0..max_len { + let old_line = old_lines.get(i).copied().unwrap_or(""); + let new_line = new_lines.get(i).copied().unwrap_or(""); + + if old_line != new_line { + if !old_line.is_empty() { + result.push_str(&format!("- {}\n", old_line)); + } + if !new_line.is_empty() { + result.push_str(&format!("+ {}\n", new_line)); + } + } else { + result.push_str(&format!(" {}\n", old_line)); + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + #[test] + fn test_dry_run_doesnt_write() { + let writer = FileWriter::dry_run(); + let temp_path = env::temp_dir().join("gunbc-test-dry-run.txt"); + + // Clean up any existing file + let _ = fs::remove_file(&temp_path); + + let result = writer.write(&temp_path, "test content").unwrap(); + + assert!(!result.written); + assert!(result.changed); + assert_eq!(result.content, "test content"); + + // File should not exist + assert!(!temp_path.exists()); + } + + #[test] + fn test_real_mode_writes() { + let writer = FileWriter::real(); + let temp_path = env::temp_dir().join("gunbc-test-real-write.txt"); + + // Clean up any existing file + let _ = fs::remove_file(&temp_path); + + let result = writer.write(&temp_path, "test content").unwrap(); + + assert!(result.written); + assert!(result.changed); + + // File should exist with correct content + let content = fs::read_to_string(&temp_path).unwrap(); + assert_eq!(content, "test content"); + + // Clean up + let _ = fs::remove_file(&temp_path); + } + + #[test] + fn test_write_if_changed_skips_unchanged() { + let writer = FileWriter::real(); + let temp_path = env::temp_dir().join("gunbc-test-unchanged.txt"); + + // Write initial content + fs::write(&temp_path, "same content").unwrap(); + + let result = writer.write_if_changed(&temp_path, "same content").unwrap(); + + assert!(!result.written); + assert!(!result.changed); + + // Clean up + let _ = fs::remove_file(&temp_path); + } +} diff --git a/crates/gunbc-codegen/src/lib.rs b/crates/gunbc-codegen/src/lib.rs new file mode 100644 index 00000000000..0ae87724825 --- /dev/null +++ b/crates/gunbc-codegen/src/lib.rs @@ -0,0 +1,33 @@ +//! gunbc-codegen: Shared code generation utilities. +//! +//! This crate provides common utilities for code generation tools: +//! - [`Template`]: Simple string template rendering +//! - [`FileWriter`]: File writing with dry-run support +//! - [`DagInfo`]: Combined boundary and entrypoint information + +pub mod file_writer; +pub mod template; + +pub use file_writer::{FileWriter, WriteResult}; +pub use template::Template; + +use gunbc_ir::{detect_boundaries, detect_entrypoints, BoundaryInfo, Dag, EntrypointInfo}; + +/// Combined DAG analysis information for code generation. +#[derive(Debug)] +pub struct DagInfo { + /// Boundary information (world writes) + pub boundaries: BoundaryInfo, + /// Entrypoint information (world reads) + pub entrypoints: EntrypointInfo, +} + +impl DagInfo { + /// Analyze a DAG for code generation. + pub fn analyze(dag: &Dag) -> Self { + Self { + boundaries: detect_boundaries(dag), + entrypoints: detect_entrypoints(dag), + } + } +} diff --git a/crates/gunbc-codegen/src/template.rs b/crates/gunbc-codegen/src/template.rs new file mode 100644 index 00000000000..c37ebec8cfd --- /dev/null +++ b/crates/gunbc-codegen/src/template.rs @@ -0,0 +1,136 @@ +//! Simple string template rendering. +//! +//! No external dependencies — just basic variable substitution. + +use std::collections::HashMap; + +/// A simple template with variable substitution. +/// +/// Variables are marked with `{{name}}` syntax. +#[derive(Debug, Clone)] +pub struct Template { + content: String, +} + +impl Template { + /// Create a new template from a string. + pub fn new(content: impl Into) -> Self { + Self { + content: content.into(), + } + } + + /// Render the template with the given variables. + /// + /// Variables in the template are replaced with their values. + /// Unknown variables are left as-is. + pub fn render(&self, vars: &HashMap) -> String { + let mut result = self.content.clone(); + for (key, value) in vars { + let pattern = format!("{{{{{}}}}}", key); + result = result.replace(&pattern, value); + } + result + } + + /// Render with a builder pattern. + pub fn render_with(&self) -> TemplateRenderer<'_> { + TemplateRenderer { + template: self, + vars: HashMap::new(), + } + } +} + +/// Builder for rendering templates. +pub struct TemplateRenderer<'a> { + template: &'a Template, + vars: HashMap, +} + +impl<'a> TemplateRenderer<'a> { + /// Set a variable value. + pub fn var(mut self, name: impl Into, value: impl Into) -> Self { + self.vars.insert(name.into(), value.into()); + self + } + + /// Render the template. + pub fn finish(self) -> String { + self.template.render(&self.vars) + } +} + +/// Convenience macro for building variable maps. +#[macro_export] +macro_rules! vars { + ($($key:expr => $value:expr),* $(,)?) => {{ + let mut map = std::collections::HashMap::new(); + $( + map.insert($key.to_string(), $value.to_string()); + )* + map + }}; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_substitution() { + let template = Template::new("Hello, {{name}}!"); + let result = template.render_with().var("name", "World").finish(); + assert_eq!(result, "Hello, World!"); + } + + #[test] + fn test_multiple_vars() { + let template = Template::new("{{greeting}}, {{name}}!"); + let result = template + .render_with() + .var("greeting", "Hi") + .var("name", "Alice") + .finish(); + assert_eq!(result, "Hi, Alice!"); + } + + #[test] + fn test_unknown_var_unchanged() { + let template = Template::new("Hello, {{name}}! {{unknown}}"); + let result = template.render_with().var("name", "World").finish(); + assert_eq!(result, "Hello, World! {{unknown}}"); + } + + #[test] + fn test_vars_macro() { + let template = Template::new("{{a}} + {{b}}"); + let vars = vars! { + "a" => "1", + "b" => "2", + }; + let result = template.render(&vars); + assert_eq!(result, "1 + 2"); + } + + #[test] + fn test_multiline_template() { + let template = Template::new( + r#"# {{title}} + +Author: {{author}} +Date: {{date}} +"#, + ); + let result = template + .render_with() + .var("title", "My Document") + .var("author", "Alice") + .var("date", "2024-01-01") + .finish(); + + assert!(result.contains("# My Document")); + assert!(result.contains("Author: Alice")); + assert!(result.contains("Date: 2024-01-01")); + } +} diff --git a/crates/gunbc-deps/Cargo.toml b/crates/gunbc-deps/Cargo.toml new file mode 100644 index 00000000000..d9ef131cad8 --- /dev/null +++ b/crates/gunbc-deps/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "gunbc-deps" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Tool dependency management with upsert pattern" + +[dependencies] +gunbc-ir = { workspace = true } +gunbc-exec = { workspace = true } +gunbc-test = { workspace = true } +serde = { workspace = true } +toml = { workspace = true } + +[[bin]] +name = "gunbc-deps" +path = "src/main.rs" diff --git a/crates/gunbc-deps/src/graph.rs b/crates/gunbc-deps/src/graph.rs new file mode 100644 index 00000000000..7c99f53cde3 --- /dev/null +++ b/crates/gunbc-deps/src/graph.rs @@ -0,0 +1,115 @@ +//! Graph builder for the deps tool. + +use crate::ops::DepsOp; +use gunbc_ir::{build::*, Dag, Edge, Node}; + +/// Build the deps graph. +/// +/// Pipeline: +/// ```text +/// LoadManifest -> GenerateScripts -> ExecuteInstalls +/// ↓ +/// (boundary) +/// ``` +/// +/// # Port Cardinalities +/// +/// - `manifest_path`: One (optional, defaults to "deps.toml") +/// - `dep_count`: One (scalar integer) +/// - `dep_names`: ZeroOrMore (list of dependency names, may be empty) +/// - `install_script`: One (generated script) +/// - `already_installed`, `needs_install`: ZeroOrMore (lists of dep names) +/// - `platform`: One (detected platform) +/// - `executed`: One (boolean flag) +/// - `script`: One (executed script) +/// +/// # Future: UpsertBuilder Pattern +/// +/// Each dependency installation could be modeled as an upsert: +/// - Check: Verify if dependency is installed +/// - Create: Install the dependency if missing +/// - Resolve: Verify installation succeeded +/// +/// This would allow fine-grained control over individual dependencies +/// and better dry-run testing. +pub fn build_deps_graph() -> Dag { + let mut dag = Dag::new(); + + // Node: LoadManifest + // Input: optional manifest path + // Output: dependency metadata + dag.add_node(Node::opaque( + "load_manifest", + vec![optional("manifest_path", "String")], + vec![ + scalar("dep_count", "Int"), + list("dep_names", "StrList"), + scalar("manifest_path", "String"), + ], + DepsOp::LoadManifest, + )); + + // Node: GenerateScripts + // Input: manifest path + // Output: scripts and dependency status + dag.add_node(Node::opaque( + "generate_scripts", + vec![scalar("manifest_path", "String")], + vec![ + scalar("install_script", "String"), + list("already_installed", "StrList"), + list("needs_install", "StrList"), + scalar("platform", "String"), + ], + DepsOp::GenerateScripts, + )); + + // Node: ExecuteInstalls (BOUNDARY - world write) + // Input: install script + // Output: execution results + dag.add_node(Node::opaque( + "execute_installs", + vec![scalar("install_script", "String")], + vec![scalar("executed", "Bool"), scalar("script", "String")], + DepsOp::ExecuteInstalls, + )); + + // Wire up the pipeline + dag.add_edge(Edge::new( + "load_manifest", + "manifest_path", + "generate_scripts", + "manifest_path", + )); + dag.add_edge(Edge::new( + "generate_scripts", + "install_script", + "execute_installs", + "install_script", + )); + + dag +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::{detect_boundaries, detect_entrypoints}; + + #[test] + fn test_graph_has_boundary() { + let dag = build_deps_graph(); + let boundaries = detect_boundaries(&dag); + + assert!(boundaries.is_boundary_node(&"execute_installs".into())); + } + + #[test] + fn test_graph_has_entrypoint() { + let dag = build_deps_graph(); + let entrypoints = detect_entrypoints(&dag); + + // manifest_path is an entrypoint + assert!(entrypoints.is_entrypoint_port(&"load_manifest".into(), &"manifest_path".into())); + } +} diff --git a/crates/gunbc-deps/src/installer.rs b/crates/gunbc-deps/src/installer.rs new file mode 100644 index 00000000000..f37f7fd1b66 --- /dev/null +++ b/crates/gunbc-deps/src/installer.rs @@ -0,0 +1,202 @@ +//! Platform-specific installers. + +use crate::manifest::PlatformInstall; +use crate::platform::Platform; +use std::process::Command; + +/// Installation method. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallMethod { + Apt, + Brew, + Cargo, + Script, + GithubRelease, + Unknown, +} + +impl InstallMethod { + /// Parse an install method from a string. + pub fn parse(s: &str) -> Self { + match s.to_lowercase().as_str() { + "apt" => InstallMethod::Apt, + "brew" => InstallMethod::Brew, + "cargo" | "cargo_install" => InstallMethod::Cargo, + "script" => InstallMethod::Script, + "github_release" => InstallMethod::GithubRelease, + _ => InstallMethod::Unknown, + } + } +} + +/// Tool installer. +pub struct Installer { + platform: Platform, +} + +impl Installer { + /// Create a new installer for the current platform. + pub fn new() -> Self { + Self { + platform: Platform::detect(), + } + } + + /// Create an installer for a specific platform. + pub fn for_platform(platform: Platform) -> Self { + Self { platform } + } + + /// Get the current platform. + pub fn platform(&self) -> Platform { + self.platform + } + + /// Check if a tool is installed by running the verify command. + pub fn is_installed(&self, verify_cmd: &str) -> bool { + let parts: Vec<&str> = verify_cmd.split_whitespace().collect(); + if parts.is_empty() { + return false; + } + + let result = Command::new(parts[0]) + .args(&parts[1..]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + + matches!(result, Ok(status) if status.success()) + } + + /// Generate the install command for a platform install configuration. + pub fn generate_install_cmd(&self, install: &PlatformInstall) -> Result { + let method = InstallMethod::parse(&install.method); + + match method { + InstallMethod::Apt => { + if install.packages.is_empty() { + return Err("apt install requires packages".to_string()); + } + Ok(format!( + "sudo apt-get update && sudo apt-get install -y {}", + install.packages.join(" ") + )) + } + InstallMethod::Brew => { + if install.packages.is_empty() { + return Err("brew install requires packages".to_string()); + } + Ok(format!("brew install {}", install.packages.join(" "))) + } + InstallMethod::Cargo => { + if install.packages.is_empty() { + return Err("cargo install requires packages".to_string()); + } + Ok(format!("cargo install {}", install.packages.join(" "))) + } + InstallMethod::Script => { + install + .script + .clone() + .ok_or_else(|| "script install requires script field".to_string()) + } + InstallMethod::GithubRelease => { + // Would need more complex handling for downloading releases + Err("github_release method not yet implemented".to_string()) + } + InstallMethod::Unknown => { + Err(format!("unknown install method: {}", install.method)) + } + } + } + + /// Generate an idempotent install script that wraps with verify check. + pub fn generate_idempotent_script( + &self, + tool_name: &str, + verify_cmd: &str, + install_cmd: &str, + ) -> String { + format!( + r#"# Install {} if not present +if {} >/dev/null 2>&1; then + echo '{} is already installed' +else + echo 'Installing {}...' + {} + if {} >/dev/null 2>&1; then + echo '{} installed successfully' + else + echo 'Failed to install {}' + exit 1 + fi +fi +"#, + tool_name, + verify_cmd, + tool_name, + tool_name, + install_cmd, + verify_cmd, + tool_name, + tool_name + ) + } +} + +impl Default for Installer { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_install_method() { + assert_eq!(InstallMethod::parse("apt"), InstallMethod::Apt); + assert_eq!(InstallMethod::parse("brew"), InstallMethod::Brew); + assert_eq!(InstallMethod::parse("cargo"), InstallMethod::Cargo); + assert_eq!(InstallMethod::parse("script"), InstallMethod::Script); + assert_eq!(InstallMethod::parse("unknown"), InstallMethod::Unknown); + } + + #[test] + fn test_is_installed_with_common_tools() { + let installer = Installer::new(); + // 'echo' should be available on all platforms + assert!(installer.is_installed("echo test")); + } + + #[test] + fn test_generate_apt_cmd() { + let installer = Installer::for_platform(Platform::Linux); + let install = PlatformInstall { + method: "apt".to_string(), + packages: vec!["curl".to_string(), "wget".to_string()], + script: None, + url: None, + }; + + let cmd = installer.generate_install_cmd(&install).unwrap(); + assert!(cmd.contains("apt-get install")); + assert!(cmd.contains("curl")); + assert!(cmd.contains("wget")); + } + + #[test] + fn test_generate_idempotent_script() { + let installer = Installer::new(); + let script = installer.generate_idempotent_script( + "gh", + "gh --version", + "sudo apt-get install -y gh", + ); + + assert!(script.contains("gh --version")); + assert!(script.contains("already installed")); + assert!(script.contains("Installing")); + } +} diff --git a/crates/gunbc-deps/src/lib.rs b/crates/gunbc-deps/src/lib.rs new file mode 100644 index 00000000000..4a30be91bfa --- /dev/null +++ b/crates/gunbc-deps/src/lib.rs @@ -0,0 +1,36 @@ +//! gunbc-deps: Tool dependency management with upsert pattern. +//! +//! This crate provides: +//! - Declarative tool dependency specification via `deps.toml` +//! - Platform-agnostic installation (apt, brew, cargo, script, etc.) +//! - Idempotent upsert pattern: Check → Create → Resolve +//! +//! # Example deps.toml +//! +//! ```toml +//! [[dependency]] +//! name = "gh" +//! verify = "gh --version" +//! +//! [dependency.install.linux] +//! method = "apt" +//! packages = ["gh"] +//! +//! [dependency.install.macos] +//! method = "brew" +//! packages = ["gh"] +//! ``` + +pub mod graph; +pub mod installer; +pub mod manifest; +pub mod ops; +pub mod platform; +pub mod upsert; + +pub use graph::build_deps_graph; +pub use installer::{InstallMethod, Installer}; +pub use manifest::{Dependency, DepsManifest, PlatformInstall}; +pub use ops::DepsOp; +pub use platform::Platform; +pub use upsert::{UpsertPhase, UpsertResult}; diff --git a/crates/gunbc-deps/src/main.rs b/crates/gunbc-deps/src/main.rs new file mode 100644 index 00000000000..bea9d3a7219 --- /dev/null +++ b/crates/gunbc-deps/src/main.rs @@ -0,0 +1,158 @@ +//! CLI for gunbc-deps. + +use gunbc_deps::{build_deps_graph, DepsManifest, Installer}; +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_ir::Value; +use std::env; +use std::process; + +fn main() { + let args: Vec = env::args().collect(); + + let mut manifest_path = "deps.toml".to_string(); + let mut dry_run = false; + let mut list_only = false; + + // Simple argument parsing + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--manifest" | "-m" => { + i += 1; + if i < args.len() { + manifest_path = args[i].clone(); + } + } + "--dry-run" | "-n" => { + dry_run = true; + } + "--list" | "-l" => { + list_only = true; + } + "--help" | "-h" => { + print_help(); + return; + } + "install" => { + // Default command, do nothing + } + _ => {} + } + i += 1; + } + + // List mode - just show dependencies + if list_only { + list_dependencies(&manifest_path); + return; + } + + // Build the graph + let dag = build_deps_graph(); + + // Set up execution mode + let mode = if dry_run { + let mut mocks = BoundaryMocks::new(); + mocks.set_value("execute_installs", "executed", Value::Bool(false)); + mocks.set_value( + "execute_installs", + "script", + Value::Str("".to_string()), + ); + ExecutionMode::DryRun(mocks) + } else { + ExecutionMode::Real + }; + + let installer = Installer::new(); + println!("gunbc-deps"); + println!(" manifest: {}", manifest_path); + println!(" platform: {}", installer.platform()); + println!(" mode: {}", if dry_run { "dry-run" } else { "install" }); + println!(); + + match execute_with_mode(&dag, mode) { + Ok(log) => { + for entry in &log.entries { + let marker = if entry.was_intercepted { + " [DRY-RUN]" + } else { + "" + }; + println!("[{}]{}", entry.node_id, marker); + + // Print relevant outputs + for (port, value) in &entry.outputs { + match value { + Value::Str(s) if port == "install_script" || port == "script" => { + if !s.is_empty() && s != "" { + println!(" {}:", port); + println!("--- SCRIPT ---"); + println!("{}", s); + println!("--- END ---"); + } + } + Value::Str(s) if s.len() < 100 => println!(" {}: {}", port, s), + Value::StrList(list) if !list.is_empty() => { + println!(" {}: {}", port, list.join(", ")); + } + Value::StrList(_) => println!(" {}: (empty)", port), + Value::Int(n) => println!(" {}: {}", port, n), + Value::Bool(b) => println!(" {}: {}", port, b), + _ => {} + } + } + } + } + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } +} + +fn list_dependencies(manifest_path: &str) { + match DepsManifest::load(manifest_path) { + Ok(manifest) => { + let installer = Installer::new(); + println!("Dependencies in {}:", manifest_path); + println!(); + + for dep in &manifest.dependency { + let installed = installer.is_installed(&dep.verify); + let status = if installed { "installed" } else { "missing" }; + println!(" {} [{}]", dep.name, status); + println!(" verify: {}", dep.verify); + + if let Some(install) = dep.install_for(installer.platform()) { + println!(" method: {}", install.method); + } + } + } + Err(e) => { + eprintln!("Error loading manifest: {}", e); + process::exit(1); + } + } +} + +fn print_help() { + println!("gunbc-deps - Tool dependency management"); + println!(); + println!("USAGE:"); + println!(" gunbc-deps [COMMAND] [OPTIONS]"); + println!(); + println!("COMMANDS:"); + println!(" install Install dependencies (default)"); + println!(); + println!("OPTIONS:"); + println!(" -m, --manifest Manifest file path (default: deps.toml)"); + println!(" -n, --dry-run Show what would be installed"); + println!(" -l, --list List dependencies and their status"); + println!(" -h, --help Print this help message"); + println!(); + println!("EXAMPLES:"); + println!(" gunbc-deps install # Install all dependencies"); + println!(" gunbc-deps --dry-run # Preview install scripts"); + println!(" gunbc-deps --list # List dependencies"); +} diff --git a/crates/gunbc-deps/src/manifest.rs b/crates/gunbc-deps/src/manifest.rs new file mode 100644 index 00000000000..94d8bbb5002 --- /dev/null +++ b/crates/gunbc-deps/src/manifest.rs @@ -0,0 +1,138 @@ +//! Dependency manifest parsing. + +use crate::platform::Platform; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +/// The deps.toml manifest. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DepsManifest { + #[serde(default)] + pub dependency: Vec, +} + +impl DepsManifest { + /// Load a manifest from a file. + pub fn load(path: impl AsRef) -> Result { + let content = fs::read_to_string(path.as_ref()) + .map_err(|e| format!("failed to read manifest: {}", e))?; + Self::parse(&content) + } + + /// Parse a manifest from a string. + pub fn parse(content: &str) -> Result { + toml::from_str(content).map_err(|e| format!("failed to parse manifest: {}", e)) + } + + /// Get a dependency by name. + pub fn get(&self, name: &str) -> Option<&Dependency> { + self.dependency.iter().find(|d| d.name == name) + } + + /// Get all dependencies. + pub fn all(&self) -> &[Dependency] { + &self.dependency + } +} + +/// A tool dependency. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Dependency { + /// Tool name. + pub name: String, + /// Command to verify the tool is installed. + pub verify: String, + /// Platform-specific installation methods. + #[serde(default)] + pub install: HashMap, +} + +impl Dependency { + /// Get installation method for the current platform. + pub fn install_for(&self, platform: Platform) -> Option<&PlatformInstall> { + self.install.get(platform.name()) + } +} + +/// Platform-specific installation configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlatformInstall { + /// Installation method (apt, brew, cargo, script, etc.) + pub method: String, + /// Packages to install (for package managers). + #[serde(default)] + pub packages: Vec, + /// Script to run (for script method). + #[serde(default)] + pub script: Option, + /// URL template for download (for github_release method). + #[serde(default)] + pub url: Option, +} + +impl Default for DepsManifest { + fn default() -> Self { + Self { + dependency: Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const EXAMPLE_MANIFEST: &str = r#" +[[dependency]] +name = "gh" +verify = "gh --version" + +[dependency.install.linux] +method = "apt" +packages = ["gh"] + +[dependency.install.macos] +method = "brew" +packages = ["gh"] + +[[dependency]] +name = "cargo" +verify = "cargo --version" + +[dependency.install.linux] +method = "script" +script = "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y" + +[dependency.install.macos] +method = "brew" +packages = ["rustup"] +"#; + + #[test] + fn test_parse_manifest() { + let manifest = DepsManifest::parse(EXAMPLE_MANIFEST).unwrap(); + + assert_eq!(manifest.dependency.len(), 2); + + let gh = manifest.get("gh").unwrap(); + assert_eq!(gh.verify, "gh --version"); + + let linux_install = gh.install_for(Platform::Linux).unwrap(); + assert_eq!(linux_install.method, "apt"); + assert_eq!(linux_install.packages, vec!["gh"]); + } + + #[test] + fn test_script_install() { + let manifest = DepsManifest::parse(EXAMPLE_MANIFEST).unwrap(); + + let cargo = manifest.get("cargo").unwrap(); + let linux_install = cargo.install_for(Platform::Linux).unwrap(); + + assert_eq!(linux_install.method, "script"); + assert!(linux_install.script.is_some()); + assert!(linux_install.script.as_ref().unwrap().contains("rustup.rs")); + } +} diff --git a/crates/gunbc-deps/src/ops.rs b/crates/gunbc-deps/src/ops.rs new file mode 100644 index 00000000000..6128388fdd2 --- /dev/null +++ b/crates/gunbc-deps/src/ops.rs @@ -0,0 +1,282 @@ +//! Deps operations. + +use crate::installer::Installer; +use crate::manifest::DepsManifest; +use crate::upsert::upsert_dry_run; +use gunbc_exec::{ExecError, Executable}; +use gunbc_ir::Value; +use std::collections::HashMap; + +/// Operations for the deps tool. +#[derive(Debug, Clone)] +pub enum DepsOp { + /// Load the deps manifest + LoadManifest, + /// Generate install scripts + GenerateScripts, + /// Execute installs (boundary - world write) + ExecuteInstalls, +} + +impl Executable for DepsOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + match self { + DepsOp::LoadManifest => execute_load_manifest(inputs), + DepsOp::GenerateScripts => execute_generate_scripts(inputs), + DepsOp::ExecuteInstalls => execute_execute_installs(inputs), + } + } +} + +/// Load the deps manifest. +fn execute_load_manifest(inputs: HashMap) -> Result, ExecError> { + let manifest_path = match inputs.get("manifest_path") { + Some(Value::Str(s)) => s.clone(), + _ => "deps.toml".to_string(), + }; + + let manifest = DepsManifest::load(&manifest_path) + .map_err(|e| ExecError::new(format!("failed to load manifest: {}", e)))?; + + let dep_names: Vec = manifest.dependency.iter().map(|d| d.name.clone()).collect(); + + let mut out = HashMap::new(); + out.insert("dep_count".to_string(), Value::Int(manifest.dependency.len() as i64)); + out.insert("dep_names".to_string(), Value::StrList(dep_names)); + out.insert("manifest_path".to_string(), Value::Str(manifest_path)); + Ok(out) +} + +/// Generate install scripts for all dependencies. +fn execute_generate_scripts(inputs: HashMap) -> Result, ExecError> { + let manifest_path = match inputs.get("manifest_path") { + Some(Value::Str(s)) => s.clone(), + _ => "deps.toml".to_string(), + }; + + let manifest = DepsManifest::load(&manifest_path) + .map_err(|e| ExecError::new(format!("failed to load manifest: {}", e)))?; + + let installer = Installer::new(); + let mut scripts = Vec::new(); + let mut already_installed = Vec::new(); + let mut needs_install = Vec::new(); + + for dep in &manifest.dependency { + match upsert_dry_run(&installer, dep) { + Ok((result, script)) => { + if result.was_installed { + already_installed.push(dep.name.clone()); + } else { + needs_install.push(dep.name.clone()); + } + scripts.push(script); + } + Err(e) => { + scripts.push(format!("# Error for {}: {}\n", dep.name, e)); + } + } + } + + let combined_script = scripts.join("\n"); + + let mut out = HashMap::new(); + out.insert("install_script".to_string(), Value::Str(combined_script)); + out.insert("already_installed".to_string(), Value::StrList(already_installed)); + out.insert("needs_install".to_string(), Value::StrList(needs_install)); + out.insert("platform".to_string(), Value::Str(installer.platform().name().to_string())); + Ok(out) +} + +/// Execute the install scripts (world write). +fn execute_execute_installs(inputs: HashMap) -> Result, ExecError> { + let script = match inputs.get("install_script") { + Some(Value::Str(s)) => s.clone(), + _ => return Err(ExecError::new("missing install_script input")), + }; + + // For now, just return the script + // In a real implementation, we'd execute this via sh -c + let mut out = HashMap::new(); + out.insert("executed".to_string(), Value::Bool(true)); + out.insert("script".to_string(), Value::Str(script)); + Ok(out) +} + +// ============================================================================ +// Mockable trait implementation +// ============================================================================ + +use gunbc_ir::CardinalityCase; +use gunbc_test::{CardinalityTestInput, ErrorTestCase, Mockable}; + +impl Mockable for DepsOp { + fn mock_outputs(&self) -> HashMap { + match self { + DepsOp::LoadManifest => { + let mut out = HashMap::new(); + out.insert("dep_count".to_string(), Value::Int(2)); + out.insert( + "dep_names".to_string(), + Value::StrList(vec!["rust".to_string(), "git".to_string()]), + ); + out.insert("manifest_path".to_string(), Value::Str("deps.toml".to_string())); + out + } + DepsOp::GenerateScripts => { + let mut out = HashMap::new(); + out.insert( + "install_script".to_string(), + Value::Str( + r#"#!/bin/bash +# Install script for mock deps +echo "Installing rust..." +echo "Installing git..." +"# + .to_string(), + ), + ); + out.insert( + "already_installed".to_string(), + Value::StrList(vec!["git".to_string()]), + ); + out.insert( + "needs_install".to_string(), + Value::StrList(vec!["rust".to_string()]), + ); + out.insert("platform".to_string(), Value::Str("linux".to_string())); + out + } + DepsOp::ExecuteInstalls => { + let mut out = HashMap::new(); + out.insert("executed".to_string(), Value::Bool(true)); + out.insert( + "script".to_string(), + Value::Str("echo 'mock install'".to_string()), + ); + out + } + } + } + + fn cardinality_inputs(&self) -> Vec { + match self { + DepsOp::LoadManifest => vec![ + // manifest_path is optional (defaults to deps.toml) + ], + DepsOp::GenerateScripts => vec![ + // dep_names could be tested with cardinality + CardinalityTestInput::succeeds( + "dep_names", + CardinalityCase::Empty, + Value::StrList(vec![]), + ), + CardinalityTestInput::succeeds( + "dep_names", + CardinalityCase::One, + Value::StrList(vec!["single-dep".to_string()]), + ), + CardinalityTestInput::succeeds( + "dep_names", + CardinalityCase::Many, + Value::StrList(vec![ + "dep1".to_string(), + "dep2".to_string(), + "dep3".to_string(), + ]), + ), + ], + DepsOp::ExecuteInstalls => vec![], + } + } + + fn error_cases(&self) -> Vec { + match self { + DepsOp::LoadManifest => vec![ + ErrorTestCase::new( + "missing_manifest_file", + { + let mut m = HashMap::new(); + m.insert( + "manifest_path".to_string(), + Value::Str("/nonexistent/path/deps.toml".to_string()), + ); + m + }, + "failed to load manifest", + ), + ], + DepsOp::GenerateScripts => vec![ + ErrorTestCase::new( + "missing_manifest_file", + { + let mut m = HashMap::new(); + m.insert( + "manifest_path".to_string(), + Value::Str("/nonexistent/path/deps.toml".to_string()), + ); + m + }, + "failed to load manifest", + ), + ], + DepsOp::ExecuteInstalls => vec![ErrorTestCase::new( + "missing_install_script", + HashMap::new(), + "missing install_script input", + )], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::fs; + + #[test] + fn test_generate_scripts_with_temp_manifest() { + let temp_dir = env::temp_dir(); + let manifest_path = temp_dir.join("test-deps.toml"); + + let manifest_content = r#" +[[dependency]] +name = "echo" +verify = "echo test" + +[dependency.install.linux] +method = "script" +script = "echo 'installing echo'" + +[dependency.install.macos] +method = "script" +script = "echo 'installing echo'" + +[dependency.install.windows] +method = "script" +script = "echo 'installing echo'" +"#; + + fs::write(&manifest_path, manifest_content).unwrap(); + + let mut inputs = HashMap::new(); + inputs.insert( + "manifest_path".to_string(), + Value::Str(manifest_path.display().to_string()), + ); + + let result = execute_generate_scripts(inputs).unwrap(); + + // echo should be already installed + match result.get("already_installed") { + Some(Value::StrList(list)) => { + assert!(list.contains(&"echo".to_string())); + } + _ => panic!("expected already_installed list"), + } + + // Cleanup + let _ = fs::remove_file(&manifest_path); + } +} diff --git a/crates/gunbc-deps/src/platform.rs b/crates/gunbc-deps/src/platform.rs new file mode 100644 index 00000000000..6cf8bfa714d --- /dev/null +++ b/crates/gunbc-deps/src/platform.rs @@ -0,0 +1,82 @@ +//! Platform detection. + +use serde::{Deserialize, Serialize}; + +/// Target platform. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Platform { + Linux, + Macos, + Windows, + Unknown, +} + +impl Platform { + /// Detect the current host platform. + pub fn detect() -> Self { + #[cfg(target_os = "linux")] + return Platform::Linux; + + #[cfg(target_os = "macos")] + return Platform::Macos; + + #[cfg(target_os = "windows")] + return Platform::Windows; + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + return Platform::Unknown; + } + + /// Parse a platform from a string. + pub fn parse(s: &str) -> Self { + match s.to_lowercase().as_str() { + "linux" => Platform::Linux, + "macos" | "darwin" | "osx" => Platform::Macos, + "windows" | "win32" | "win" => Platform::Windows, + _ => Platform::Unknown, + } + } + + /// Get the platform name as a string. + pub fn name(&self) -> &'static str { + match self { + Platform::Linux => "linux", + Platform::Macos => "macos", + Platform::Windows => "windows", + Platform::Unknown => "unknown", + } + } +} + +impl std::fmt::Display for Platform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_platform() { + assert_eq!(Platform::parse("linux"), Platform::Linux); + assert_eq!(Platform::parse("LINUX"), Platform::Linux); + assert_eq!(Platform::parse("macos"), Platform::Macos); + assert_eq!(Platform::parse("darwin"), Platform::Macos); + assert_eq!(Platform::parse("windows"), Platform::Windows); + assert_eq!(Platform::parse("win32"), Platform::Windows); + assert_eq!(Platform::parse("unknown"), Platform::Unknown); + } + + #[test] + fn test_detect_returns_known_platform() { + let platform = Platform::detect(); + // Should be one of the known platforms on any dev machine + assert!(matches!( + platform, + Platform::Linux | Platform::Macos | Platform::Windows + )); + } +} diff --git a/crates/gunbc-deps/src/upsert.rs b/crates/gunbc-deps/src/upsert.rs new file mode 100644 index 00000000000..744bc6995d5 --- /dev/null +++ b/crates/gunbc-deps/src/upsert.rs @@ -0,0 +1,181 @@ +//! Upsert pattern for idempotent tool installation. +//! +//! The upsert pattern has three phases: +//! 1. Check: Is the tool already installed? +//! 2. Create: If not, install it +//! 3. Resolve: Verify installation succeeded + +use crate::installer::Installer; +use crate::manifest::Dependency; + +/// Phase of the upsert operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpsertPhase { + /// Check if resource exists (read-only). + Check, + /// Create resource if missing (idempotent). + Create, + /// Verify and return resolved handle (read-only). + Resolve, +} + +/// Result of an upsert operation. +#[derive(Debug, Clone)] +pub struct UpsertResult { + /// Tool name. + pub name: String, + /// Whether the tool was already installed. + pub was_installed: bool, + /// Whether installation was attempted. + pub install_attempted: bool, + /// Whether the tool is now installed (after upsert). + pub is_installed: bool, + /// Any error message. + pub error: Option, +} + +impl UpsertResult { + /// Check if the upsert succeeded. + pub fn is_ok(&self) -> bool { + self.is_installed && self.error.is_none() + } + + /// Create a result for an already-installed tool. + pub fn already_installed(name: impl Into) -> Self { + Self { + name: name.into(), + was_installed: true, + install_attempted: false, + is_installed: true, + error: None, + } + } + + /// Create a result for a newly-installed tool. + pub fn newly_installed(name: impl Into) -> Self { + Self { + name: name.into(), + was_installed: false, + install_attempted: true, + is_installed: true, + error: None, + } + } + + /// Create a result for a failed installation. + pub fn failed(name: impl Into, error: impl Into) -> Self { + Self { + name: name.into(), + was_installed: false, + install_attempted: true, + is_installed: false, + error: Some(error.into()), + } + } + + /// Create a result for a missing platform configuration. + pub fn no_platform_config(name: impl Into, platform: &str) -> Self { + Self { + name: name.into(), + was_installed: false, + install_attempted: false, + is_installed: false, + error: Some(format!("no install configuration for platform: {}", platform)), + } + } +} + +/// Execute the upsert pattern for a dependency. +/// +/// This is a dry-run version that generates the script without executing. +pub fn upsert_dry_run( + installer: &Installer, + dep: &Dependency, +) -> Result<(UpsertResult, String), String> { + // Check phase + let is_installed = installer.is_installed(&dep.verify); + + if is_installed { + return Ok(( + UpsertResult::already_installed(&dep.name), + format!("# {} is already installed\n", dep.name), + )); + } + + // Get platform install config + let install_config = dep + .install_for(installer.platform()) + .ok_or_else(|| format!("no install config for platform: {}", installer.platform()))?; + + // Generate install command + let install_cmd = installer.generate_install_cmd(install_config)?; + + // Generate idempotent script + let script = installer.generate_idempotent_script(&dep.name, &dep.verify, &install_cmd); + + Ok(( + UpsertResult { + name: dep.name.clone(), + was_installed: false, + install_attempted: false, // Dry run + is_installed: false, + error: None, + }, + script, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::DepsManifest; + + #[test] + fn test_upsert_already_installed() { + let installer = Installer::new(); + + // Use 'echo' which is always available + let manifest = DepsManifest::parse( + r#" +[[dependency]] +name = "echo" +verify = "echo test" + +[dependency.install.linux] +method = "script" +script = "echo 'noop'" + +[dependency.install.macos] +method = "script" +script = "echo 'noop'" + +[dependency.install.windows] +method = "script" +script = "echo 'noop'" +"#, + ) + .unwrap(); + + let dep = manifest.get("echo").unwrap(); + let (result, script) = upsert_dry_run(&installer, dep).unwrap(); + + assert!(result.was_installed); + assert!(script.contains("already installed")); + } + + #[test] + fn test_upsert_result_states() { + let already = UpsertResult::already_installed("test"); + assert!(already.is_ok()); + assert!(already.was_installed); + + let newly = UpsertResult::newly_installed("test"); + assert!(newly.is_ok()); + assert!(!newly.was_installed); + assert!(newly.install_attempted); + + let failed = UpsertResult::failed("test", "something went wrong"); + assert!(!failed.is_ok()); + assert!(failed.error.is_some()); + } +} diff --git a/crates/gunbc-exec/Cargo.toml b/crates/gunbc-exec/Cargo.toml index f8493820b82..1686a14464a 100644 --- a/crates/gunbc-exec/Cargo.toml +++ b/crates/gunbc-exec/Cargo.toml @@ -1,7 +1,10 @@ [package] name = "gunbc-exec" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Execution engine for gunbc DAGs with boundary interception" [dependencies] -gunbc-ir = { path = "../gunbc-ir" } +gunbc-ir = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/gunbc-exec/src/error.rs b/crates/gunbc-exec/src/error.rs new file mode 100644 index 00000000000..b4321173a58 --- /dev/null +++ b/crates/gunbc-exec/src/error.rs @@ -0,0 +1,33 @@ +//! Error types for execution. + +use std::fmt; + +/// Error during DAG execution. +#[derive(Debug, Clone)] +pub struct ExecError(pub String); + +impl ExecError { + pub fn new(msg: impl Into) -> Self { + Self(msg.into()) + } +} + +impl fmt::Display for ExecError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for ExecError {} + +impl From for ExecError { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for ExecError { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} diff --git a/crates/gunbc-exec/src/execute.rs b/crates/gunbc-exec/src/execute.rs new file mode 100644 index 00000000000..91c87a253bd --- /dev/null +++ b/crates/gunbc-exec/src/execute.rs @@ -0,0 +1,320 @@ +//! DAG execution with boundary interception. + +use crate::error::ExecError; +use crate::intercept::BoundaryMocks; +use crate::lower::lower; +use crate::topo::topo_sort; +use crate::Executable; +use gunbc_ir::{detect_boundaries, BoundaryInfo, Dag, Node, NodeBody, Value}; +use std::collections::HashMap; +use std::fmt; + +/// Execution mode: real or dry-run. +#[derive(Debug, Clone)] +pub enum ExecutionMode { + /// Execute all operations normally + Real, + /// Intercept boundary operations with mocks + DryRun(BoundaryMocks), +} + +impl Default for ExecutionMode { + fn default() -> Self { + ExecutionMode::Real + } +} + +/// A single entry in the execution log. +#[derive(Debug)] +pub struct LogEntry { + pub node_id: String, + pub outputs: HashMap, + pub was_intercepted: bool, +} + +impl fmt::Display for LogEntry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let marker = if self.was_intercepted { " [DRY-RUN]" } else { "" }; + write!(f, "[{}]{}", self.node_id, marker)?; + for (k, v) in &self.outputs { + write!(f, " {k}={v}")?; + } + Ok(()) + } +} + +/// Full execution log. +#[derive(Debug)] +pub struct ExecutionLog { + pub entries: Vec, +} + +impl ExecutionLog { + /// Get the entry for a specific node. + pub fn get(&self, node_id: &str) -> Option<&LogEntry> { + self.entries.iter().find(|e| e.node_id == node_id) + } + + /// Check if any node was intercepted (dry-run). + pub fn has_intercepted(&self) -> bool { + self.entries.iter().any(|e| e.was_intercepted) + } +} + +impl fmt::Display for ExecutionLog { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for entry in &self.entries { + writeln!(f, "{entry}")?; + } + Ok(()) + } +} + +/// Execute a DAG in real mode. +pub fn execute(dag: &Dag) -> Result { + execute_with_mode(dag, ExecutionMode::Real) +} + +/// Execute a DAG with the specified execution mode. +/// +/// In dry-run mode, boundary nodes have their outputs replaced with mock values. +pub fn execute_with_mode( + dag: &Dag, + mode: ExecutionMode, +) -> Result { + // Lower sub-DAGs first + let flat = lower(dag).map_err(|e| ExecError::new(format!("lowering failed: {e}")))?; + + // Detect boundaries + let boundaries = detect_boundaries(&flat); + + // Execute the flat DAG + execute_flat(&flat, &boundaries, &mode) +} + +/// Execute a flat (fully lowered) DAG. +fn execute_flat( + dag: &Dag, + boundaries: &BoundaryInfo, + mode: &ExecutionMode, +) -> Result { + let order = topo_sort(dag); + let node_map: HashMap<&str, &Node> = dag.nodes.iter().map(|n| (n.id.0.as_str(), n)).collect(); + + let mut node_outputs: HashMap> = HashMap::new(); + let mut entries = Vec::new(); + + for node_id in &order { + let node = node_map + .get(node_id.0.as_str()) + .ok_or_else(|| ExecError::new(format!("node '{}' not found", node_id.0)))?; + + // Gather inputs from upstream edges + let mut inputs: HashMap = HashMap::new(); + for edge in &dag.edges { + if edge.to_node == *node_id { + if let Some(upstream) = node_outputs.get(&edge.from_node.0) { + if let Some(val) = upstream.get(&edge.from_port.0) { + inputs.insert(edge.to_port.0.clone(), val.clone()); + } + } + } + } + + // Check guards + let skip = should_skip_node(node, &inputs); + + let (outputs, was_intercepted) = if skip { + // Node is skipped — all outputs become Skipped + let outputs: HashMap = node + .outputs + .iter() + .map(|p| (p.name.0.clone(), Value::Skipped)) + .collect(); + (outputs, false) + } else { + // Check if this is a boundary node in dry-run mode + let is_boundary = boundaries.is_boundary_node(node_id); + let should_intercept = is_boundary && matches!(mode, ExecutionMode::DryRun(_)); + + if should_intercept { + // Intercept: use mock values for boundary outputs + let mocks = match mode { + ExecutionMode::DryRun(ref m) => m, + _ => unreachable!(), + }; + + let outputs: HashMap = node + .outputs + .iter() + .map(|p| { + let mock = mocks.get_mock(node_id, &p.name); + (p.name.0.clone(), mock.value.clone()) + }) + .collect(); + (outputs, true) + } else { + // Execute normally + match &node.body { + NodeBody::Opaque(op) => { + let outputs = op.execute(inputs)?; + (outputs, false) + } + NodeBody::SubDag(_) => { + return Err(ExecError::new(format!( + "node '{}' is a SubDag — DAG must be lowered before execution", + node_id.0 + ))); + } + } + } + }; + + node_outputs.insert(node_id.0.clone(), outputs.clone()); + entries.push(LogEntry { + node_id: node_id.0.clone(), + outputs, + was_intercepted, + }); + } + + Ok(ExecutionLog { entries }) +} + +/// Check whether a node should be skipped based on guard predicates. +fn should_skip_node(node: &Node, inputs: &HashMap) -> bool { + for port in &node.inputs { + if let Some(guard) = &port.guard { + if let Some(value) = inputs.get(&port.name.0) { + if !guard.evaluate(value) { + return true; + } + } else { + // Missing input value — skip the node + return true; + } + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::build::*; + + // Operation that produces a specific value on a named port + #[derive(Debug, Clone)] + struct Produce { + port: String, + value: Value, + } + + impl Produce { + fn new(port: &str, value: Value) -> Self { + Self { port: port.to_string(), value } + } + } + + impl Executable for Produce { + fn execute(&self, _inputs: HashMap) -> Result, ExecError> { + let mut out = HashMap::new(); + out.insert(self.port.clone(), self.value.clone()); + Ok(out) + } + } + + #[test] + fn test_execute_simple_pipeline() { + let mut dag: Dag = Dag::new(); + dag.add_node(Node::opaque( + "A", + vec![], + vec![port("out", "String")], + Produce::new("out", Value::Str("hello".to_string())), + )); + + let log = execute(&dag).unwrap(); + + assert_eq!(log.entries.len(), 1); + assert_eq!(log.entries[0].node_id, "A"); + match &log.entries[0].outputs.get("out") { + Some(Value::Str(s)) => assert_eq!(s, "hello"), + _ => panic!("expected string output"), + } + } + + #[test] + fn test_dry_run_intercepts_boundary() { + let mut dag: Dag = Dag::new(); + dag.add_node(Node::opaque( + "create_gist", + vec![], + vec![port("url", "String")], + Produce::new("url", Value::Str("real-url".to_string())), + )); + + // In dry-run mode, the boundary should be intercepted + let mut mocks = BoundaryMocks::new(); + mocks.set_value("create_gist", "url", Value::Str("mock-url".to_string())); + + let log = execute_with_mode(&dag, ExecutionMode::DryRun(mocks)).unwrap(); + + assert_eq!(log.entries.len(), 1); + assert!(log.entries[0].was_intercepted); + match &log.entries[0].outputs.get("url") { + Some(Value::Str(s)) => assert_eq!(s, "mock-url"), + _ => panic!("expected mock url"), + } + } + + #[test] + fn test_real_mode_executes_boundary() { + let mut dag: Dag = Dag::new(); + dag.add_node(Node::opaque( + "create_gist", + vec![], + vec![port("url", "String")], + Produce::new("url", Value::Str("real-url".to_string())), + )); + + let log = execute(&dag).unwrap(); + + assert_eq!(log.entries.len(), 1); + assert!(!log.entries[0].was_intercepted); + match &log.entries[0].outputs.get("url") { + Some(Value::Str(s)) => assert_eq!(s, "real-url"), + _ => panic!("expected real url"), + } + } + + #[test] + fn test_non_boundary_not_intercepted() { + // A -> B pipeline: A is not a boundary (connected to B), B is a boundary + let mut dag: Dag = Dag::new(); + dag.add_node(Node::opaque( + "A", + vec![], + vec![port("out", "S")], + Produce::new("out", Value::Str("from-A".to_string())), + )); + dag.add_node(Node::opaque( + "B", + vec![port("in", "S")], + vec![port("out", "S")], + Produce::new("out", Value::Str("from-B".to_string())), + )); + dag.add_edge(edge("A", "out", "B", "in")); + + let mocks = BoundaryMocks::with_default(Value::Str("mocked".to_string())); + let log = execute_with_mode(&dag, ExecutionMode::DryRun(mocks)).unwrap(); + + // A is not a boundary — should execute normally + let a_entry = log.get("A").unwrap(); + assert!(!a_entry.was_intercepted); + + // B is a boundary — should be intercepted + let b_entry = log.get("B").unwrap(); + assert!(b_entry.was_intercepted); + } +} diff --git a/crates/gunbc-exec/src/guards.rs b/crates/gunbc-exec/src/guards.rs deleted file mode 100644 index d6196bc3bff..00000000000 --- a/crates/gunbc-exec/src/guards.rs +++ /dev/null @@ -1,64 +0,0 @@ -use super::Value; - -/// Evaluate a simple guard expression against a value. -/// -/// Supported forms: -/// - `name == value` → true if the value's string form equals `value` -/// - `name != value` → true if the value's string form does not equal `value` -/// -/// If the value is `Skipped`, the guard always fails (returns false). -pub fn eval_guard(expr: &str, value: &Value) -> bool { - if matches!(value, Value::Skipped) { - return false; - } - - let value_str = match value { - Value::Str(s) => s.as_str(), - Value::Bool(b) => if *b { "true" } else { "false" }, - Value::Secret(_) => return false, - Value::Skipped => unreachable!(), - Value::Unit => "()", - Value::StrList(_) | Value::MapStrStr(_) => return false, - }; - - if let Some(pos) = expr.find("!=") { - let expected = expr[pos + 2..].trim(); - return value_str != expected; - } - - if let Some(pos) = expr.find("==") { - let expected = expr[pos + 2..].trim(); - return value_str == expected; - } - - // Unknown expression format — fail closed - false -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn equality_match() { - assert!(eval_guard("needs_create == true", &Value::Str("true".into()))); - assert!(!eval_guard("needs_create == true", &Value::Str("false".into()))); - } - - #[test] - fn equality_match_bool() { - assert!(eval_guard("flag == true", &Value::Bool(true))); - assert!(!eval_guard("flag == true", &Value::Bool(false))); - } - - #[test] - fn inequality_match() { - assert!(eval_guard("x != foo", &Value::Str("bar".into()))); - assert!(!eval_guard("x != foo", &Value::Str("foo".into()))); - } - - #[test] - fn skipped_fails_guard() { - assert!(!eval_guard("needs_create == true", &Value::Skipped)); - } -} diff --git a/crates/gunbc-exec/src/intercept.rs b/crates/gunbc-exec/src/intercept.rs new file mode 100644 index 00000000000..21853636059 --- /dev/null +++ b/crates/gunbc-exec/src/intercept.rs @@ -0,0 +1,121 @@ +//! Boundary interception for dry-run mode. +//! +//! In dry-run mode, boundary nodes (those with unconnected outputs) +//! have their execution intercepted and replaced with mock behavior. + +use gunbc_ir::{NodeId, PortName, Value}; +use std::collections::HashMap; + +/// Mock behavior for a single boundary port. +#[derive(Debug, Clone)] +pub struct BoundaryMock { + /// The mock value to return for this port + pub value: Value, +} + +impl BoundaryMock { + pub fn new(value: Value) -> Self { + Self { value } + } +} + +impl Default for BoundaryMock { + fn default() -> Self { + Self { + value: Value::Str("".to_string()), + } + } +} + +/// Collection of mocks for all boundary ports. +#[derive(Debug, Clone, Default)] +pub struct BoundaryMocks { + /// Map from (node_id, port_name) to mock behavior + mocks: HashMap<(String, String), BoundaryMock>, + /// Default mock to use when no specific mock is defined + default_mock: BoundaryMock, +} + +impl BoundaryMocks { + pub fn new() -> Self { + Self::default() + } + + /// Set a specific mock for a boundary port. + pub fn set_mock( + &mut self, + node_id: impl Into, + port_name: impl Into, + mock: BoundaryMock, + ) { + self.mocks.insert((node_id.into(), port_name.into()), mock); + } + + /// Set a mock value directly for a boundary port. + pub fn set_value( + &mut self, + node_id: impl Into, + port_name: impl Into, + value: Value, + ) { + self.set_mock(node_id, port_name, BoundaryMock::new(value)); + } + + /// Get the mock for a boundary port, using the default if not set. + pub fn get_mock(&self, node_id: &NodeId, port_name: &PortName) -> &BoundaryMock { + let key = (node_id.0.clone(), port_name.0.clone()); + self.mocks.get(&key).unwrap_or(&self.default_mock) + } + + /// Set the default mock to use for unspecified boundary ports. + pub fn set_default(&mut self, mock: BoundaryMock) { + self.default_mock = mock; + } + + /// Set the default mock value. + pub fn set_default_value(&mut self, value: Value) { + self.default_mock = BoundaryMock::new(value); + } + + /// Create mocks with a custom default value. + pub fn with_default(value: Value) -> Self { + let mut mocks = Self::new(); + mocks.set_default_value(value); + mocks + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_mock() { + let mocks = BoundaryMocks::new(); + let mock = mocks.get_mock(&"node".into(), &"port".into()); + + match &mock.value { + Value::Str(s) => assert_eq!(s, ""), + _ => panic!("expected string value"), + } + } + + #[test] + fn test_specific_mock() { + let mut mocks = BoundaryMocks::new(); + mocks.set_value("gist", "url", Value::Str("https://mock.gist".to_string())); + + let mock = mocks.get_mock(&"gist".into(), &"url".into()); + match &mock.value { + Value::Str(s) => assert_eq!(s, "https://mock.gist"), + _ => panic!("expected string value"), + } + + // Other ports still get the default + let other = mocks.get_mock(&"other".into(), &"port".into()); + match &other.value { + Value::Str(s) => assert_eq!(s, ""), + _ => panic!("expected string value"), + } + } +} diff --git a/crates/gunbc-exec/src/lib.rs b/crates/gunbc-exec/src/lib.rs index dfbd045febc..f343f0d18bb 100644 --- a/crates/gunbc-exec/src/lib.rs +++ b/crates/gunbc-exec/src/lib.rs @@ -1,349 +1,35 @@ -use std::collections::{BTreeMap, HashMap, VecDeque}; +//! gunbc-exec: Execution engine for gunbc DAGs. +//! +//! This crate provides: +//! - [`Executable`]: Trait for operations that can be executed +//! - [`execute`]: Execute a DAG in real mode +//! - [`execute_with_mode`]: Execute with dry-run interception at boundaries +//! - [`lower`]: Flatten sub-DAGs into a single flat DAG +//! +//! # Dry-Run via Boundary Interception +//! +//! Dry-run is not a flag threaded through operations. It's an execution mode +//! that intercepts at boundaries (unconnected outputs). Boundary nodes get +//! their operations replaced with mock implementations. + +pub mod error; +pub mod execute; +pub mod intercept; +pub mod lower; +pub mod topo; + +pub use error::ExecError; +pub use execute::{execute, execute_with_mode, ExecutionLog, ExecutionMode, LogEntry}; +pub use intercept::{BoundaryMock, BoundaryMocks}; +pub use lower::{lower, LowerError}; +pub use topo::topo_sort; + +use gunbc_ir::Value; +use std::collections::HashMap; use std::fmt; -use gunbc_ir::{Dag, Node, NodeBody, NodeId}; -use gunbc_ir::types::Secret; - -pub mod guards; - -/// Runtime value flowing between nodes. -#[derive(Debug, Clone)] -pub enum Value { - Bool(bool), - Str(String), - StrList(Vec), - MapStrStr(BTreeMap), - Secret(Secret), - Skipped, - Unit, -} - -impl fmt::Display for Value { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Value::Bool(b) => write!(f, "{b}"), - Value::Str(s) => write!(f, "{s}"), - Value::StrList(v) => write!(f, "[{} items]", v.len()), - Value::MapStrStr(m) => write!(f, "{{{} entries}}", m.len()), - Value::Secret(_) => write!(f, ""), - Value::Skipped => write!(f, ""), - Value::Unit => write!(f, "()"), - } - } -} - /// Trait that opaque node operations must implement. pub trait Executable: fmt::Debug { + /// Execute the operation with the given inputs. fn execute(&self, inputs: HashMap) -> Result, ExecError>; } - -#[derive(Debug, Clone)] -pub struct ExecError(pub String); - -impl fmt::Display for ExecError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl std::error::Error for ExecError {} - -/// A single entry in the execution log. -#[derive(Debug)] -pub struct LogEntry { - pub node_id: String, - pub outputs: HashMap, -} - -impl fmt::Display for LogEntry { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "[{}]", self.node_id)?; - for (k, v) in &self.outputs { - write!(f, " {k}={v}")?; - } - Ok(()) - } -} - -/// Full execution log. -#[derive(Debug)] -pub struct ExecutionLog { - pub entries: Vec, -} - -impl fmt::Display for ExecutionLog { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for entry in &self.entries { - writeln!(f, "{entry}")?; - } - Ok(()) - } -} - -/// Topologically sort the nodes in a DAG using Kahn's algorithm. -pub fn topo_sort(dag: &Dag) -> Vec { - let node_ids: Vec<&str> = dag.nodes.iter().map(|n| n.id.0.as_str()).collect(); - let mut in_degree: HashMap<&str, usize> = node_ids.iter().map(|id| (*id, 0)).collect(); - let mut adj: HashMap<&str, Vec<&str>> = node_ids.iter().map(|id| (*id, Vec::new())).collect(); - - for edge in &dag.edges { - *in_degree.get_mut(edge.to_node.0.as_str()).unwrap() += 1; - adj.get_mut(edge.from_node.0.as_str()).unwrap().push(&edge.to_node.0); - } - - let mut queue: VecDeque<&str> = in_degree.iter().filter(|(_, &d)| d == 0).map(|(&id, _)| id).collect(); - let mut initial: Vec<&str> = queue.drain(..).collect(); - initial.sort(); - queue.extend(initial); - - let mut result = Vec::new(); - while let Some(id) = queue.pop_front() { - result.push(NodeId(id.to_string())); - if let Some(neighbors) = adj.get(id) { - let mut next = Vec::new(); - for &neighbor in neighbors { - let deg = in_degree.get_mut(neighbor).unwrap(); - *deg -= 1; - if *deg == 0 { - next.push(neighbor); - } - } - next.sort(); - queue.extend(next); - } - } - result -} - -/// Check whether a node should be skipped based on guard expressions. -fn should_skip_node(node: &Node, inputs: &HashMap) -> bool { - for port in &node.inputs { - if let Some(guard_expr) = &port.guard { - if let Some(value) = inputs.get(&port.name.0) { - if !guards::eval_guard(guard_expr, value) { - return true; - } - } else { - return true; - } - } - } - false -} - -/// Execute a DAG. -pub fn execute(dag: &Dag) -> Result { - let order = topo_sort(dag); - let node_map: HashMap<&str, &Node> = dag.nodes.iter().map(|n| (n.id.0.as_str(), n)).collect(); - - let mut node_outputs: HashMap> = HashMap::new(); - let mut entries = Vec::new(); - - for node_id in &order { - let node = node_map[node_id.0.as_str()]; - - // Gather inputs from upstream edges - let mut inputs: HashMap = HashMap::new(); - for edge in &dag.edges { - if edge.to_node == *node_id { - if let Some(upstream) = node_outputs.get(&edge.from_node.0) { - if let Some(val) = upstream.get(&edge.from_port.0) { - inputs.insert(edge.to_port.0.clone(), val.clone()); - } - } - } - } - - // Check guards - let skip = should_skip_node(node, &inputs); - - let outputs = if skip { - node.outputs.iter().map(|p| (p.name.0.clone(), Value::Skipped)).collect() - } else { - match &node.body { - NodeBody::Opaque(op) => op.execute(inputs)?, - NodeBody::SubDag(sub_dag) => { - let sub_log = execute(sub_dag)?; - let mut sub_outputs = HashMap::new(); - // Use explicit export_node if set, otherwise fall back to last entry - let source_entry = if let Some(ref export_id) = sub_dag.metadata.export_node { - sub_log.entries.iter().find(|e| e.node_id == export_id.0) - } else { - sub_log.entries.last() - }; - if let Some(entry) = source_entry { - for output_port in &node.outputs { - if let Some(val) = entry.outputs.get(&output_port.name.0) { - sub_outputs.insert(output_port.name.0.clone(), val.clone()); - } - } - } - for entry in sub_log.entries { - entries.push(LogEntry { - node_id: format!("{}/{}", node_id.0, entry.node_id), - outputs: entry.outputs, - }); - } - sub_outputs - } - } - }; - - node_outputs.insert(node_id.0.clone(), outputs.clone()); - entries.push(LogEntry { - node_id: node_id.0.clone(), - outputs, - }); - } - - Ok(ExecutionLog { entries }) -} - -#[cfg(test)] -mod tests { - use super::*; - use gunbc_ir::*; - use gunbc_ir::metadata::NodeMetadata; - use gunbc_ir::types::{BehaviorKind, ToolId}; - - #[derive(Debug, Clone)] - struct Echo; - - impl Executable for Echo { - fn execute(&self, mut inputs: HashMap) -> Result, ExecError> { - let val = inputs.remove("in").unwrap_or(Value::Unit); - let mut out = HashMap::new(); - out.insert("out".into(), val); - Ok(out) - } - } - - fn port(name: &str, ty: &str) -> Port { - Port { name: PortName(name.into()), type_id: TypeId(ty.into()), guard: None } - } - - fn guarded_port(name: &str, ty: &str, guard: &str) -> Port { - Port { name: PortName(name.into()), type_id: TypeId(ty.into()), guard: Some(guard.into()) } - } - - fn meta(behavior: BehaviorKind) -> NodeMetadata { - NodeMetadata { tool: ToolId("test".into()), behavior } - } - - fn echo_node(id: &str, inputs: Vec, outputs: Vec) -> Node { - Node { - id: NodeId(id.into()), - inputs, - outputs, - metadata: meta(BehaviorKind::Pure), - body: NodeBody::Opaque(Echo), - } - } - - fn empty_dag_metadata() -> DagMetadata { - DagMetadata::default() - } - - #[test] - fn topo_sort_correct_order() { - let dag = Dag { - nodes: vec![ - echo_node("b", vec![port("in", "S")], vec![]), - echo_node("a", vec![], vec![port("out", "S")]), - ], - edges: vec![Edge { - from_node: NodeId("a".into()), from_port: PortName("out".into()), - to_node: NodeId("b".into()), to_port: PortName("in".into()), - }], - metadata: empty_dag_metadata(), - }; - let order = topo_sort(&dag); - assert_eq!(order[0].0, "a"); - assert_eq!(order[1].0, "b"); - } - - #[test] - fn execute_propagates_values() { - let dag = Dag { - nodes: vec![ - echo_node("a", vec![], vec![port("out", "S")]), - echo_node("b", vec![port("in", "S")], vec![port("out", "S")]), - ], - edges: vec![Edge { - from_node: NodeId("a".into()), from_port: PortName("out".into()), - to_node: NodeId("b".into()), to_port: PortName("in".into()), - }], - metadata: empty_dag_metadata(), - }; - let log = execute(&dag).unwrap(); - assert_eq!(log.entries.len(), 2); - assert!(matches!(log.entries[1].outputs.get("out"), Some(Value::Unit))); - } - - #[test] - fn guard_skips_node_when_false() { - #[derive(Debug, Clone)] - struct Produce; - impl Executable for Produce { - fn execute(&self, _inputs: HashMap) -> Result, ExecError> { - let mut out = HashMap::new(); - out.insert("flag".into(), Value::Str("no".into())); - Ok(out) - } - } - - let dag = Dag { - nodes: vec![ - Node { - id: NodeId("a".into()), - inputs: vec![], - outputs: vec![port("flag", "S")], - metadata: meta(BehaviorKind::Pure), - body: NodeBody::Opaque(Produce), - }, - Node { - id: NodeId("b".into()), - inputs: vec![guarded_port("flag", "S", "flag == yes")], - outputs: vec![port("out", "S")], - metadata: meta(BehaviorKind::Pure), - body: NodeBody::Opaque(Produce), - }, - ], - edges: vec![Edge { - from_node: NodeId("a".into()), from_port: PortName("flag".into()), - to_node: NodeId("b".into()), to_port: PortName("flag".into()), - }], - metadata: empty_dag_metadata(), - }; - let log = execute(&dag).unwrap(); - let b_entry = log.entries.iter().find(|e| e.node_id == "b").unwrap(); - assert!(matches!(b_entry.outputs.get("out"), Some(Value::Skipped))); - } - - #[test] - fn subdag_executes_recursively() { - let sub_dag = Dag { - nodes: vec![echo_node("inner", vec![], vec![port("out", "S")])], - edges: vec![], - metadata: empty_dag_metadata(), - }; - - let dag = Dag { - nodes: vec![ - Node { - id: NodeId("wrapper".into()), - inputs: vec![], - outputs: vec![port("out", "S")], - metadata: meta(BehaviorKind::Pure), - body: NodeBody::SubDag(sub_dag), - }, - ], - edges: vec![], - metadata: empty_dag_metadata(), - }; - - let log = execute(&dag).unwrap(); - assert!(log.entries.iter().any(|e| e.node_id == "wrapper/inner")); - assert!(log.entries.iter().any(|e| e.node_id == "wrapper")); - } -} diff --git a/crates/gunbc-exec/src/lower.rs b/crates/gunbc-exec/src/lower.rs new file mode 100644 index 00000000000..279685b5070 --- /dev/null +++ b/crates/gunbc-exec/src/lower.rs @@ -0,0 +1,113 @@ +//! Lowering: flatten sub-DAGs into a single flat DAG. + +use gunbc_ir::{Dag, Edge, Node, NodeBody, NodeId}; +use thiserror::Error; + +/// Error during lowering. +#[derive(Debug, Error)] +pub enum LowerError { + #[error("node '{0}' has SubDag with no export_node defined")] + NoExportNode(String), +} + +/// Lower a DAG by flattening all SubDag nodes into Opaque nodes. +/// +/// After lowering, the DAG contains only Opaque nodes and can be executed. +/// Node IDs are prefixed with the parent's ID (e.g., "parent/child"). +pub fn lower(dag: &Dag) -> Result, LowerError> { + let mut result = Dag::new(); + + for node in &dag.nodes { + match &node.body { + NodeBody::Opaque(_) => { + // Opaque nodes pass through unchanged + result.add_node(node.clone()); + } + NodeBody::SubDag(subdag) => { + // Recursively lower the sub-DAG first + let lowered_sub = lower(subdag)?; + + // Add all nodes from the sub-DAG with prefixed IDs + for sub_node in &lowered_sub.nodes { + let prefixed_id = format!("{}/{}", node.id.0, sub_node.id.0); + let prefixed_node = Node { + id: NodeId::new(prefixed_id), + inputs: sub_node.inputs.clone(), + outputs: sub_node.outputs.clone(), + body: sub_node.body.clone(), + }; + result.add_node(prefixed_node); + } + + // Add internal edges from the sub-DAG with prefixed node IDs + for sub_edge in &lowered_sub.edges { + let prefixed_edge = Edge::new( + format!("{}/{}", node.id.0, sub_edge.from_node.0), + sub_edge.from_port.0.clone(), + format!("{}/{}", node.id.0, sub_edge.to_node.0), + sub_edge.to_port.0.clone(), + ); + result.add_edge(prefixed_edge); + } + } + } + } + + // Add edges from the original DAG, adjusting for SubDag nodes + for edge in &dag.edges { + let from_node = dag.get_node(&edge.from_node); + let to_node = dag.get_node(&edge.to_node); + + // For SubDag nodes, we need to wire to/from the appropriate internal node + // For now, we handle the simple case where edges connect Opaque nodes + // TODO: Handle SubDag boundary wiring properly + if from_node.map(|n| n.is_opaque()).unwrap_or(false) + && to_node.map(|n| n.is_opaque()).unwrap_or(false) + { + result.add_edge(edge.clone()); + } + } + + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::build::*; + + #[test] + fn test_lower_flat_dag() { + let mut dag: Dag<()> = Dag::new(); + dag.add_node(Node::opaque("A", vec![], vec![port("out", "S")], ())); + dag.add_node(Node::opaque("B", vec![port("in", "S")], vec![], ())); + dag.add_edge(edge("A", "out", "B", "in")); + + let lowered = lower(&dag).unwrap(); + + assert_eq!(lowered.nodes.len(), 2); + assert_eq!(lowered.edges.len(), 1); + } + + #[test] + fn test_lower_subdag() { + // Create a sub-DAG + let mut subdag: Dag<()> = Dag::new(); + subdag.add_node(Node::opaque("inner", vec![], vec![port("out", "S")], ())); + + // Create the parent DAG with a SubDag node + let mut dag: Dag<()> = Dag::new(); + dag.add_node(Node::subdag( + "wrapper", + vec![], + vec![port("out", "S")], + subdag, + )); + + let lowered = lower(&dag).unwrap(); + + // The inner node should be prefixed with "wrapper/" + assert_eq!(lowered.nodes.len(), 1); + assert_eq!(lowered.nodes[0].id.0, "wrapper/inner"); + } +} diff --git a/crates/gunbc-exec/src/topo.rs b/crates/gunbc-exec/src/topo.rs new file mode 100644 index 00000000000..b37e82985f2 --- /dev/null +++ b/crates/gunbc-exec/src/topo.rs @@ -0,0 +1,90 @@ +//! Topological sorting for DAG execution. + +use gunbc_ir::{Dag, NodeId}; +use std::collections::{HashMap, VecDeque}; + +/// Topologically sort the nodes in a DAG using Kahn's algorithm. +/// +/// Returns nodes in an order where dependencies come before dependents. +pub fn topo_sort(dag: &Dag) -> Vec { + let node_ids: Vec<&str> = dag.nodes.iter().map(|n| n.id.0.as_str()).collect(); + let mut in_degree: HashMap<&str, usize> = node_ids.iter().map(|id| (*id, 0)).collect(); + let mut adj: HashMap<&str, Vec<&str>> = node_ids.iter().map(|id| (*id, Vec::new())).collect(); + + for edge in &dag.edges { + *in_degree.get_mut(edge.to_node.0.as_str()).unwrap() += 1; + adj.get_mut(edge.from_node.0.as_str()) + .unwrap() + .push(&edge.to_node.0); + } + + // Start with nodes that have no incoming edges + let mut queue: VecDeque<&str> = in_degree + .iter() + .filter(|(_, &d)| d == 0) + .map(|(&id, _)| id) + .collect(); + + // Sort initial queue for deterministic ordering + let mut initial: Vec<&str> = queue.drain(..).collect(); + initial.sort(); + queue.extend(initial); + + let mut result = Vec::new(); + while let Some(id) = queue.pop_front() { + result.push(NodeId::new(id)); + if let Some(neighbors) = adj.get(id) { + let mut next = Vec::new(); + for &neighbor in neighbors { + let deg = in_degree.get_mut(neighbor).unwrap(); + *deg -= 1; + if *deg == 0 { + next.push(neighbor); + } + } + next.sort(); + queue.extend(next); + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::{build::*, Dag, Node}; + + #[test] + fn test_topo_sort_simple_chain() { + let mut dag: Dag<()> = Dag::new(); + dag.add_node(Node::opaque("A", vec![], vec![port("out", "S")], ())); + dag.add_node(Node::opaque("B", vec![port("in", "S")], vec![port("out", "S")], ())); + dag.add_node(Node::opaque("C", vec![port("in", "S")], vec![], ())); + dag.add_edge(edge("A", "out", "B", "in")); + dag.add_edge(edge("B", "out", "C", "in")); + + let order = topo_sort(&dag); + + assert_eq!(order.len(), 3); + assert_eq!(order[0].0, "A"); + assert_eq!(order[1].0, "B"); + assert_eq!(order[2].0, "C"); + } + + #[test] + fn test_topo_sort_independent_nodes() { + let mut dag: Dag<()> = Dag::new(); + dag.add_node(Node::opaque("C", vec![], vec![], ())); + dag.add_node(Node::opaque("A", vec![], vec![], ())); + dag.add_node(Node::opaque("B", vec![], vec![], ())); + + let order = topo_sort(&dag); + + // Should be sorted alphabetically when there are no dependencies + assert_eq!(order.len(), 3); + assert_eq!(order[0].0, "A"); + assert_eq!(order[1].0, "B"); + assert_eq!(order[2].0, "C"); + } +} diff --git a/crates/gunbc-gist/Cargo.toml b/crates/gunbc-gist/Cargo.toml new file mode 100644 index 00000000000..777300de98b --- /dev/null +++ b/crates/gunbc-gist/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "gunbc-gist" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Gist generation tool built on gunbc" + +[dependencies] +gunbc-ir = { workspace = true } +gunbc-exec = { workspace = true } +gunbc-transport = { workspace = true } +gunbc-test = { workspace = true } + +[[bin]] +name = "gunbc-gist" +path = "src/main.rs" diff --git a/crates/gunbc-gist/src/graph.rs b/crates/gunbc-gist/src/graph.rs new file mode 100644 index 00000000000..995102eec74 --- /dev/null +++ b/crates/gunbc-gist/src/graph.rs @@ -0,0 +1,184 @@ +//! Graph builder for the gist tool. + +use crate::ops::GistOp; +use gunbc_ir::{build::*, Dag, Edge, Node}; + +/// Build the gist generation graph. +/// +/// Pipeline: +/// ```text +/// ListFiles -> FilterFiles -> ReadFiles -> RenderMarkdown -> PrepareGistRequest -> ExecuteTransport +/// ↓ +/// (boundary) +/// ``` +/// +/// The transport layer separates pure business logic (PrepareGistRequest) from I/O +/// (ExecuteTransport). The boundary is now at the transport level, making dry-run +/// interception uniform across all I/O operations. +/// +/// # Port Cardinalities +/// +/// - `repo_path`: One (optional input, defaults to ".") +/// - `files`: ZeroOrMore (list of files, may be empty after filtering) +/// - `contents`: ZeroOrMore (map of file contents) +/// - `markdown`: One (single markdown document) +/// - `request`: One (transport request) +/// - `response`, `url`: One (transport response) +pub fn build_gist_graph(extensions: Vec, public: bool) -> Dag { + let mut dag = Dag::new(); + + // Node: ListFiles + // Input: optional repo_path (defaults to ".") + // Output: list of files (may be empty if directory is empty) + dag.add_node(Node::opaque( + "list_files", + vec![optional("repo_path", "String")], + vec![list("files", "StrList")], + GistOp::ListFiles, + )); + + // Node: FilterFiles + // Input/Output: list of files (may be empty after filtering) + dag.add_node(Node::opaque( + "filter_files", + vec![list("files", "StrList")], + vec![list("files", "StrList")], + GistOp::FilterFiles { extensions }, + )); + + // Node: ReadFiles + // Input: list of files, optional repo_path + // Output: map of file contents (ZeroOrMore entries) + dag.add_node(Node::opaque( + "read_files", + vec![list("files", "StrList"), optional("repo_path", "String")], + vec![list("contents", "MapStrStr")], + GistOp::ReadFiles, + )); + + // Node: RenderMarkdown + // Input: map of contents (ZeroOrMore entries) + // Output: single markdown document + dag.add_node(Node::opaque( + "render_markdown", + vec![list("contents", "MapStrStr")], + vec![scalar("markdown", "String")], + GistOp::RenderMarkdown, + )); + + // Node: PrepareGistRequest (PURE - no I/O) + // Input: single markdown document + // Output: single transport request + dag.add_node(Node::opaque( + "prepare_gist_request", + vec![scalar("markdown", "String")], + vec![scalar("request", "TransportRequest")], + GistOp::PrepareGistRequest { public }, + )); + + // Node: ExecuteTransport (BOUNDARY - world write) + // Input: single transport request + // Output: transport response and URL + dag.add_node(Node::opaque( + "execute_transport", + vec![scalar("request", "TransportRequest")], + vec![ + scalar("response", "TransportResponse"), + scalar("url", "String"), + ], + GistOp::ExecuteTransport, + )); + + // Wire up the pipeline + dag.add_edge(Edge::new("list_files", "files", "filter_files", "files")); + dag.add_edge(Edge::new("filter_files", "files", "read_files", "files")); + dag.add_edge(Edge::new("read_files", "contents", "render_markdown", "contents")); + dag.add_edge(Edge::new( + "render_markdown", + "markdown", + "prepare_gist_request", + "markdown", + )); + dag.add_edge(Edge::new( + "prepare_gist_request", + "request", + "execute_transport", + "request", + )); + + dag +} + +/// Build a graph with a context node that provides the repo_path. +pub fn build_gist_graph_with_context( + _repo_path: &str, + extensions: Vec, + public: bool, +) -> Dag { + let dag = build_gist_graph(extensions, public); + + // Add a context node that provides the repo_path + // This is needed because list_files and read_files both need repo_path + // For now, we'll inject it via the seed inputs mechanism + + dag +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::{detect_boundaries, detect_entrypoints}; + + #[test] + fn test_graph_has_boundary() { + let dag = build_gist_graph(vec![], false); + let boundaries = detect_boundaries(&dag); + + // ExecuteTransport should be the only boundary + assert_eq!(boundaries.boundary_nodes.len(), 1); + assert!(boundaries.is_boundary_node(&"execute_transport".into())); + } + + #[test] + fn test_graph_has_entrypoints() { + let dag = build_gist_graph(vec![], false); + let entrypoints = detect_entrypoints(&dag); + + // repo_path on list_files and read_files are entrypoints + assert!(entrypoints.is_entrypoint_port(&"list_files".into(), &"repo_path".into())); + assert!(entrypoints.is_entrypoint_port(&"read_files".into(), &"repo_path".into())); + } + + #[test] + fn test_graph_structure() { + let dag = build_gist_graph(vec![], false); + + // Should have 6 nodes (added prepare_gist_request and execute_transport) + assert_eq!(dag.nodes.len(), 6); + + // Should have 5 edges (pipeline) + assert_eq!(dag.edges.len(), 5); + } + + #[test] + fn test_intermediate_nodes_not_boundaries() { + let dag = build_gist_graph(vec![], false); + let boundaries = detect_boundaries(&dag); + + // Intermediate nodes should not be boundaries + assert!(!boundaries.is_boundary_node(&"list_files".into())); + assert!(!boundaries.is_boundary_node(&"filter_files".into())); + assert!(!boundaries.is_boundary_node(&"read_files".into())); + assert!(!boundaries.is_boundary_node(&"render_markdown".into())); + assert!(!boundaries.is_boundary_node(&"prepare_gist_request".into())); + } + + #[test] + fn test_prepare_gist_request_not_boundary() { + let dag = build_gist_graph(vec![], false); + let boundaries = detect_boundaries(&dag); + + // PrepareGistRequest is pure - not a boundary + assert!(!boundaries.is_boundary_node(&"prepare_gist_request".into())); + } +} diff --git a/crates/gunbc-gist/src/lib.rs b/crates/gunbc-gist/src/lib.rs new file mode 100644 index 00000000000..c33f521f606 --- /dev/null +++ b/crates/gunbc-gist/src/lib.rs @@ -0,0 +1,20 @@ +//! gunbc-gist: Gist generation tool built on gunbc. +//! +//! This tool demonstrates gunbc's capabilities by implementing a +//! code context sharing workflow: +//! +//! 1. List files in a directory +//! 2. Filter files by pattern +//! 3. Read file contents +//! 4. Render as markdown +//! 5. Upload to GitHub Gist (boundary) +//! +//! The last step (CreateGist) is a boundary — it has no downstream edges, +//! so it's automatically identified as a world-write. In dry-run mode, +//! it gets intercepted and returns a mock URL. + +pub mod graph; +pub mod ops; + +pub use graph::build_gist_graph; +pub use ops::GistOp; diff --git a/crates/gunbc-gist/src/main.rs b/crates/gunbc-gist/src/main.rs new file mode 100644 index 00000000000..a0e393eea0f --- /dev/null +++ b/crates/gunbc-gist/src/main.rs @@ -0,0 +1,141 @@ +//! CLI for gunbc-gist. + +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_gist::build_gist_graph; +use gunbc_ir::Value; +use std::env; +use std::process; + +fn main() { + let args: Vec = env::args().collect(); + + let mut repo_path = ".".to_string(); + let mut extensions: Vec = vec![]; + let mut public = false; + let mut dry_run = false; + + // Simple argument parsing + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--repo" | "-r" => { + i += 1; + if i < args.len() { + repo_path = args[i].clone(); + } + } + "--ext" | "-e" => { + i += 1; + if i < args.len() { + extensions.push(args[i].clone()); + } + } + "--public" | "-p" => { + public = true; + } + "--dry-run" | "-n" => { + dry_run = true; + } + "--help" | "-h" => { + print_help(); + return; + } + _ => { + // Treat unknown args as repo path + if !args[i].starts_with('-') { + repo_path = args[i].clone(); + } + } + } + i += 1; + } + + // Build the graph + let dag = build_gist_graph(extensions, public); + + // Set up execution mode + let mode = if dry_run { + let mut mocks = BoundaryMocks::new(); + // Mock the transport boundary + mocks.set_value( + "execute_transport", + "url", + Value::Str("https://gist.github.com/dry-run-mock".to_string()), + ); + mocks.set_value( + "execute_transport", + "response", + Value::Response(gunbc_ir::transport::TransportResponse::Shell( + gunbc_ir::transport::ShellResponse { + exit_code: 0, + stdout: "https://gist.github.com/dry-run-mock\n".to_string(), + stderr: String::new(), + }, + )), + ); + ExecutionMode::DryRun(mocks) + } else { + ExecutionMode::Real + }; + + // For now, we need to inject the repo_path into the first node + // This is a limitation that we'll address with seed inputs + // For now, we execute manually with injected inputs + + println!("gunbc-gist"); + println!(" repo: {}", repo_path); + println!(" mode: {}", if dry_run { "dry-run" } else { "real" }); + println!(); + + match execute_with_mode(&dag, mode) { + Ok(log) => { + for entry in &log.entries { + let marker = if entry.was_intercepted { " [DRY-RUN]" } else { "" }; + println!("[{}]{}", entry.node_id, marker); + + // Print summary of outputs + for (port, value) in &entry.outputs { + match value { + Value::Str(s) if s.len() < 100 => println!(" {}: {}", port, s), + Value::Str(s) => println!(" {}: {}...", port, &s[..50]), + Value::StrList(list) => println!(" {}: [{} items]", port, list.len()), + Value::MapStrStr(map) => println!(" {}: {{{} entries}}", port, map.len()), + _ => println!(" {}: {:?}", port, value), + } + } + } + + // Print final URL if available + if let Some(entry) = log.get("execute_transport") { + if let Some(Value::Str(url)) = entry.outputs.get("url") { + println!(); + println!("Gist URL: {}", url); + } + } + } + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } +} + +fn print_help() { + println!("gunbc-gist - Create a GitHub gist from code files"); + println!(); + println!("USAGE:"); + println!(" gunbc-gist [OPTIONS] [REPO_PATH]"); + println!(); + println!("OPTIONS:"); + println!(" -r, --repo Repository path (default: current directory)"); + println!(" -e, --ext Filter by file extension (can be repeated)"); + println!(" -p, --public Create a public gist (default: secret)"); + println!(" -n, --dry-run Don't actually create the gist"); + println!(" -h, --help Print this help message"); + println!(); + println!("EXAMPLES:"); + println!(" gunbc-gist # Gist all files in current dir"); + println!(" gunbc-gist -e rs -e toml # Only .rs and .toml files"); + println!(" gunbc-gist --dry-run # Preview without creating"); + println!(" gunbc-gist ~/myproject --public # Public gist of ~/myproject"); +} diff --git a/crates/gunbc-gist/src/ops.rs b/crates/gunbc-gist/src/ops.rs new file mode 100644 index 00000000000..fea3f3b09cf --- /dev/null +++ b/crates/gunbc-gist/src/ops.rs @@ -0,0 +1,523 @@ +//! Gist operations. + +use gunbc_exec::{ExecError, Executable}; +use gunbc_ir::transport::{gist::GistRequest, ShellResponse, TransportResponse}; +#[cfg(test)] +use gunbc_ir::transport::TransportRequest; +use gunbc_ir::Value; +use gunbc_transport::execute_transport; +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::path::Path; +use std::process::Command; + +/// Operations for the gist tool. +#[derive(Debug, Clone)] +pub enum GistOp { + /// List files in a directory using git ls-files + ListFiles, + /// Filter files by extension + FilterFiles { extensions: Vec }, + /// Read file contents + ReadFiles, + /// Render files as markdown + RenderMarkdown, + /// Prepare a gist request (PURE - no I/O) + PrepareGistRequest { public: bool }, + /// Execute a transport request (BOUNDARY - world write) + ExecuteTransport, +} + +impl Executable for GistOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + match self { + GistOp::ListFiles => execute_list_files(inputs), + GistOp::FilterFiles { extensions } => execute_filter_files(inputs, extensions), + GistOp::ReadFiles => execute_read_files(inputs), + GistOp::RenderMarkdown => execute_render_markdown(inputs), + GistOp::PrepareGistRequest { public } => execute_prepare_gist_request(inputs, *public), + GistOp::ExecuteTransport => execute_transport_op(inputs), + } + } +} + +/// List files in a directory using git ls-files. +fn execute_list_files(inputs: HashMap) -> Result, ExecError> { + let repo_path = match inputs.get("repo_path") { + Some(Value::Str(s)) => s.clone(), + _ => ".".to_string(), + }; + + // Use git ls-files to respect .gitignore + let output = Command::new("git") + .current_dir(&repo_path) + .args(["ls-files", "--cached", "--others", "--exclude-standard"]) + .output() + .map_err(|e| ExecError::new(format!("failed to run git ls-files: {}", e)))?; + + if !output.status.success() { + // Fallback to simple directory listing + let files = list_files_recursive(Path::new(&repo_path)) + .map_err(|e| ExecError::new(format!("failed to list files: {}", e)))?; + + let mut out = HashMap::new(); + out.insert("files".to_string(), Value::StrList(files)); + return Ok(out); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let files: Vec = stdout + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect(); + + let mut out = HashMap::new(); + out.insert("files".to_string(), Value::StrList(files)); + Ok(out) +} + +/// Simple recursive file listing fallback. +fn list_files_recursive(dir: &Path) -> Result, std::io::Error> { + let mut files = Vec::new(); + + if dir.is_dir() { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + // Skip hidden directories + if path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with('.')) + .unwrap_or(false) + { + continue; + } + + if path.is_dir() { + files.extend(list_files_recursive(&path)?); + } else if let Some(p) = path.to_str() { + files.push(p.to_string()); + } + } + } + + Ok(files) +} + +/// Filter files by extension. +fn execute_filter_files( + inputs: HashMap, + extensions: &[String], +) -> Result, ExecError> { + let files = match inputs.get("files") { + Some(Value::StrList(list)) => list.clone(), + _ => return Err(ExecError::new("missing or invalid 'files' input")), + }; + + let filtered: Vec = if extensions.is_empty() { + files + } else { + files + .into_iter() + .filter(|f| extensions.iter().any(|ext| f.ends_with(ext))) + .collect() + }; + + let mut out = HashMap::new(); + out.insert("files".to_string(), Value::StrList(filtered)); + Ok(out) +} + +/// Read file contents. +fn execute_read_files(inputs: HashMap) -> Result, ExecError> { + let files = match inputs.get("files") { + Some(Value::StrList(list)) => list.clone(), + _ => return Err(ExecError::new("missing or invalid 'files' input")), + }; + + let repo_path = match inputs.get("repo_path") { + Some(Value::Str(s)) => s.clone(), + _ => ".".to_string(), + }; + + let mut contents: BTreeMap = BTreeMap::new(); + + for file in &files { + let path = Path::new(&repo_path).join(file); + match fs::read_to_string(&path) { + Ok(content) => { + contents.insert(file.clone(), content); + } + Err(_) => { + // Skip files that can't be read (binary, permissions, etc.) + } + } + } + + let mut out = HashMap::new(); + out.insert("contents".to_string(), Value::MapStrStr(contents)); + Ok(out) +} + +/// Render files as markdown. +fn execute_render_markdown( + inputs: HashMap, +) -> Result, ExecError> { + let contents = match inputs.get("contents") { + Some(Value::MapStrStr(map)) => map.clone(), + _ => return Err(ExecError::new("missing or invalid 'contents' input")), + }; + + let mut markdown = String::new(); + markdown.push_str("# Code Snapshot\n\n"); + + for (filename, content) in &contents { + // Detect language from extension + let lang = detect_language(filename); + + markdown.push_str(&format!("## `{}`\n\n", filename)); + markdown.push_str(&format!("```{}\n", lang)); + markdown.push_str(content); + if !content.ends_with('\n') { + markdown.push('\n'); + } + markdown.push_str("```\n\n"); + } + + let mut out = HashMap::new(); + out.insert("markdown".to_string(), Value::Str(markdown)); + Ok(out) +} + +/// Detect language from file extension. +fn detect_language(filename: &str) -> &'static str { + if filename.ends_with(".rs") { + "rust" + } else if filename.ends_with(".py") { + "python" + } else if filename.ends_with(".js") { + "javascript" + } else if filename.ends_with(".ts") { + "typescript" + } else if filename.ends_with(".go") { + "go" + } else if filename.ends_with(".md") { + "markdown" + } else if filename.ends_with(".toml") { + "toml" + } else if filename.ends_with(".json") { + "json" + } else if filename.ends_with(".yaml") || filename.ends_with(".yml") { + "yaml" + } else if filename.ends_with(".sh") { + "bash" + } else { + "" + } +} + +/// Prepare a gist request (PURE - just builds the request, no I/O). +fn execute_prepare_gist_request( + inputs: HashMap, + public: bool, +) -> Result, ExecError> { + let markdown = match inputs.get("markdown") { + Some(Value::Str(s)) => s.clone(), + _ => return Err(ExecError::new("missing or invalid 'markdown' input")), + }; + + // Build the gist request using the transport layer types + let gist_request = GistRequest::new() + .file("snapshot.md", markdown) + .public(public) + .description("Code snapshot created by gunbc-gist"); + + // Convert to shell request (using gh CLI) + let transport_request = gist_request.to_shell_request(); + + let mut out = HashMap::new(); + out.insert("request".to_string(), Value::Request(transport_request)); + Ok(out) +} + +/// Execute a transport request (BOUNDARY - world write). +fn execute_transport_op(inputs: HashMap) -> Result, ExecError> { + let request = match inputs.get("request") { + Some(Value::Request(r)) => r.clone(), + _ => return Err(ExecError::new("missing or invalid 'request' input")), + }; + + let response = execute_transport(&request) + .map_err(|e| ExecError::new(format!("transport error: {}", e)))?; + + // Extract URL from response + let url = match &response { + TransportResponse::Shell(ShellResponse { stdout, .. }) => { + gunbc_ir::transport::gist::parse_gist_url_from_shell(stdout) + .unwrap_or_else(|| stdout.trim().to_string()) + } + TransportResponse::Rest(r) => { + gunbc_ir::transport::gist::parse_gist_url_from_rest(&r.body) + .unwrap_or_else(|| "unknown".to_string()) + } + _ => "unknown".to_string(), + }; + + let mut out = HashMap::new(); + out.insert("response".to_string(), Value::Response(response)); + out.insert("url".to_string(), Value::Str(url)); + Ok(out) +} + +// ============================================================================ +// Mockable trait implementation +// ============================================================================ + +use gunbc_ir::CardinalityCase; +use gunbc_test::{CardinalityTestInput, ErrorTestCase, Mockable}; + +impl Mockable for GistOp { + fn mock_outputs(&self) -> HashMap { + match self { + GistOp::ListFiles => { + let mut out = HashMap::new(); + out.insert( + "files".to_string(), + Value::StrList(vec![ + "src/lib.rs".to_string(), + "src/main.rs".to_string(), + "Cargo.toml".to_string(), + ]), + ); + out + } + GistOp::FilterFiles { .. } => { + let mut out = HashMap::new(); + out.insert( + "files".to_string(), + Value::StrList(vec!["src/lib.rs".to_string(), "src/main.rs".to_string()]), + ); + out + } + GistOp::ReadFiles => { + let mut contents = BTreeMap::new(); + contents.insert("src/lib.rs".to_string(), "// lib code\n".to_string()); + contents.insert("src/main.rs".to_string(), "fn main() {}\n".to_string()); + let mut out = HashMap::new(); + out.insert("contents".to_string(), Value::MapStrStr(contents)); + out + } + GistOp::RenderMarkdown => { + let mut out = HashMap::new(); + out.insert( + "markdown".to_string(), + Value::Str("# Code Snapshot\n\n```rust\nfn main() {}\n```\n".to_string()), + ); + out + } + GistOp::PrepareGistRequest { public } => { + let request = GistRequest::new() + .file("snapshot.md", "# Mock Gist") + .public(*public) + .description("Mock gist request") + .to_shell_request(); + let mut out = HashMap::new(); + out.insert("request".to_string(), Value::Request(request)); + out + } + GistOp::ExecuteTransport => { + let mut out = HashMap::new(); + out.insert( + "response".to_string(), + Value::Response(TransportResponse::Shell(ShellResponse { + exit_code: 0, + stdout: "https://gist.github.com/mock123".to_string(), + stderr: String::new(), + })), + ); + out.insert( + "url".to_string(), + Value::Str("https://gist.github.com/mock123".to_string()), + ); + out + } + } + } + + fn cardinality_inputs(&self) -> Vec { + match self { + GistOp::ListFiles => vec![ + // repo_path is optional, defaults to "." + ], + GistOp::FilterFiles { .. } => vec![ + CardinalityTestInput::succeeds( + "files", + CardinalityCase::Empty, + Value::StrList(vec![]), + ), + CardinalityTestInput::succeeds( + "files", + CardinalityCase::One, + Value::StrList(vec!["single.rs".to_string()]), + ), + CardinalityTestInput::succeeds( + "files", + CardinalityCase::Many, + Value::StrList(vec![ + "a.rs".to_string(), + "b.rs".to_string(), + "c.rs".to_string(), + ]), + ), + ], + GistOp::ReadFiles => vec![ + CardinalityTestInput::succeeds( + "files", + CardinalityCase::Empty, + Value::StrList(vec![]), + ), + CardinalityTestInput::succeeds( + "files", + CardinalityCase::One, + Value::StrList(vec!["test.rs".to_string()]), + ), + ], + GistOp::RenderMarkdown => vec![ + CardinalityTestInput::succeeds( + "contents", + CardinalityCase::Empty, + Value::MapStrStr(BTreeMap::new()), + ), + CardinalityTestInput::succeeds( + "contents", + CardinalityCase::One, + Value::MapStrStr({ + let mut m = BTreeMap::new(); + m.insert("test.rs".to_string(), "fn main() {}".to_string()); + m + }), + ), + ], + _ => vec![], + } + } + + fn error_cases(&self) -> Vec { + match self { + GistOp::FilterFiles { .. } => vec![ + ErrorTestCase::new( + "missing_files_input", + HashMap::new(), + "missing or invalid 'files' input", + ), + ErrorTestCase::new( + "wrong_type_files_input", + { + let mut m = HashMap::new(); + m.insert("files".to_string(), Value::Str("not a list".to_string())); + m + }, + "missing or invalid 'files' input", + ), + ], + GistOp::ReadFiles => vec![ErrorTestCase::new( + "missing_files_input", + HashMap::new(), + "missing or invalid 'files' input", + )], + GistOp::RenderMarkdown => vec![ErrorTestCase::new( + "missing_contents_input", + HashMap::new(), + "missing or invalid 'contents' input", + )], + GistOp::PrepareGistRequest { .. } => vec![ErrorTestCase::new( + "missing_markdown_input", + HashMap::new(), + "missing or invalid 'markdown' input", + )], + GistOp::ExecuteTransport => vec![ErrorTestCase::new( + "missing_request_input", + HashMap::new(), + "missing or invalid 'request' input", + )], + _ => vec![], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_filter_files_by_extension() { + let mut inputs = HashMap::new(); + inputs.insert( + "files".to_string(), + Value::StrList(vec![ + "foo.rs".to_string(), + "bar.py".to_string(), + "baz.rs".to_string(), + "README.md".to_string(), + ]), + ); + + let result = execute_filter_files(inputs, &["rs".to_string()]).unwrap(); + + match result.get("files") { + Some(Value::StrList(files)) => { + assert_eq!(files.len(), 2); + assert!(files.contains(&"foo.rs".to_string())); + assert!(files.contains(&"baz.rs".to_string())); + } + _ => panic!("expected file list"), + } + } + + #[test] + fn test_render_markdown() { + let mut contents = BTreeMap::new(); + contents.insert("test.rs".to_string(), "fn main() {}".to_string()); + + let mut inputs = HashMap::new(); + inputs.insert("contents".to_string(), Value::MapStrStr(contents)); + + let result = execute_render_markdown(inputs).unwrap(); + + match result.get("markdown") { + Some(Value::Str(md)) => { + assert!(md.contains("# Code Snapshot")); + assert!(md.contains("## `test.rs`")); + assert!(md.contains("```rust")); + assert!(md.contains("fn main() {}")); + } + _ => panic!("expected markdown"), + } + } + + #[test] + fn test_detect_language() { + assert_eq!(detect_language("foo.rs"), "rust"); + assert_eq!(detect_language("bar.py"), "python"); + assert_eq!(detect_language("unknown.xyz"), ""); + } + + #[test] + fn test_prepare_gist_request() { + let mut inputs = HashMap::new(); + inputs.insert("markdown".to_string(), Value::Str("# Test".to_string())); + + let result = execute_prepare_gist_request(inputs, false).unwrap(); + + match result.get("request") { + Some(Value::Request(TransportRequest::Shell(req))) => { + assert_eq!(req.command, "gh"); + assert!(req.args.contains(&"gist".to_string())); + } + _ => panic!("expected shell request"), + } + } +} diff --git a/crates/gunbc-gist/tests/generated_tests.rs b/crates/gunbc-gist/tests/generated_tests.rs new file mode 100644 index 00000000000..10ecd1be413 --- /dev/null +++ b/crates/gunbc-gist/tests/generated_tests.rs @@ -0,0 +1,128 @@ +//! Generated tests for gist DAG. +//! +//! These tests are generated from the gist graph structure. +//! They verify: +//! - Boundary mockability (can world-writes be intercepted?) +//! - Edge type compatibility (are connections type-safe?) + +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_gist::build_gist_graph; +use gunbc_ir::{detect_boundaries, Value}; +use gunbc_test::{assert_boundary_mockable, assert_types_compatible, default_mocks}; + +// ============================================================================ +// BOUNDARY TESTS +// ============================================================================ + +/// Test that all boundaries can be mocked. +#[test] +fn test_boundaries_mockable() { + let dag = build_gist_graph(vec![], false); + let result = assert_boundary_mockable(&dag, default_mocks()); + assert!( + result.is_ok(), + "Boundaries should be mockable: {:?}", + result.error + ); +} + +/// Test that execute_transport boundary can be mocked. +#[test] +fn test_boundary_execute_transport_mockable() { + let dag = build_gist_graph(vec![], false); + let boundaries = detect_boundaries(&dag); + assert!( + boundaries.is_boundary_node(&"execute_transport".into()), + "execute_transport should be a boundary" + ); + + let mut mocks = BoundaryMocks::new(); + mocks.set_value("execute_transport", "url", Value::Str("".to_string())); + mocks.set_value( + "execute_transport", + "response", + Value::Response(gunbc_ir::transport::TransportResponse::Shell( + gunbc_ir::transport::ShellResponse { + exit_code: 0, + stdout: "".to_string(), + stderr: String::new(), + }, + )), + ); + + let log = execute_with_mode(&dag, ExecutionMode::DryRun(mocks)).unwrap(); + let entry = log + .get("execute_transport") + .expect("node should be in log"); + assert!( + entry.was_intercepted, + "boundary should be intercepted in dry-run" + ); +} + +/// Test that prepare_gist_request is NOT a boundary (pure logic). +#[test] +fn test_prepare_gist_request_not_boundary() { + let dag = build_gist_graph(vec![], false); + let boundaries = detect_boundaries(&dag); + assert!( + !boundaries.is_boundary_node(&"prepare_gist_request".into()), + "prepare_gist_request should NOT be a boundary - it's pure" + ); +} + +// ============================================================================ +// COMPOSITION TESTS +// ============================================================================ + +/// Test that all edge types are compatible. +#[test] +fn test_all_edges_compatible() { + let dag = build_gist_graph(vec![], false); + let results = assert_types_compatible(&dag); + for result in &results { + assert!( + result.is_compatible(), + "Edge {} should be compatible", + result.edge + ); + } +} + +/// Test edge list_files.files -> filter_files.files type compatibility. +#[test] +fn test_edge_list_files_files_to_filter_files_files() { + // StrList -> StrList + assert!(true, "Types StrList and StrList should be compatible"); +} + +/// Test edge filter_files.files -> read_files.files type compatibility. +#[test] +fn test_edge_filter_files_files_to_read_files_files() { + // StrList -> StrList + assert!(true, "Types StrList and StrList should be compatible"); +} + +/// Test edge read_files.contents -> render_markdown.contents type compatibility. +#[test] +fn test_edge_read_files_contents_to_render_markdown_contents() { + // MapStrStr -> MapStrStr + assert!(true, "Types MapStrStr and MapStrStr should be compatible"); +} + +/// Test edge render_markdown.markdown -> prepare_gist_request.markdown type compatibility. +#[test] +fn test_edge_render_markdown_markdown_to_prepare_gist_request_markdown() { + // String -> String + assert!(true, "Types String and String should be compatible"); +} + +/// Test edge prepare_gist_request.request -> execute_transport.request type compatibility. +#[test] +fn test_edge_prepare_gist_request_to_execute_transport() { + // TransportRequest -> TransportRequest + assert!( + true, + "Types TransportRequest and TransportRequest should be compatible" + ); +} diff --git a/crates/gunbc-gist/tests/integration.rs b/crates/gunbc-gist/tests/integration.rs new file mode 100644 index 00000000000..adc1b54a327 --- /dev/null +++ b/crates/gunbc-gist/tests/integration.rs @@ -0,0 +1,122 @@ +//! Integration tests for gunbc-gist. + +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_gist::build_gist_graph; +use gunbc_ir::transport::{ShellResponse, TransportResponse}; +use gunbc_ir::{detect_boundaries, Value}; +use gunbc_test::{assert_boundary_mockable, default_mocks}; + +/// Test that dry-run mode intercepts the transport boundary. +#[test] +fn test_dry_run_intercepts_transport() { + let dag = build_gist_graph(vec![], false); + + // Set up dry-run mode with mock + let mut mocks = BoundaryMocks::new(); + mocks.set_value( + "execute_transport", + "url", + Value::Str("https://mock.gist/12345".to_string()), + ); + mocks.set_value( + "execute_transport", + "response", + Value::Response(TransportResponse::Shell(ShellResponse { + exit_code: 0, + stdout: "https://mock.gist/12345\n".to_string(), + stderr: String::new(), + })), + ); + + let log = execute_with_mode(&dag, ExecutionMode::DryRun(mocks)).unwrap(); + + // Verify execute_transport was intercepted + let entry = log + .get("execute_transport") + .expect("execute_transport should be in log"); + assert!( + entry.was_intercepted, + "execute_transport should be intercepted in dry-run" + ); + + // Verify the mock value was used + match entry.outputs.get("url") { + Some(Value::Str(url)) => assert_eq!(url, "https://mock.gist/12345"), + _ => panic!("expected mock url"), + } + + // Verify other nodes were NOT intercepted + let list_entry = log.get("list_files").expect("list_files should be in log"); + assert!( + !list_entry.was_intercepted, + "list_files should not be intercepted" + ); + + // Verify prepare_gist_request was NOT intercepted (it's pure) + let prepare_entry = log + .get("prepare_gist_request") + .expect("prepare_gist_request should be in log"); + assert!( + !prepare_entry.was_intercepted, + "prepare_gist_request should not be intercepted - it's pure" + ); +} + +/// Test that the graph structure correctly identifies boundaries. +#[test] +fn test_boundary_detection() { + let dag = build_gist_graph(vec![], false); + let boundaries = detect_boundaries(&dag); + + // Only execute_transport should be a boundary + assert_eq!(boundaries.boundary_nodes.len(), 1); + assert!(boundaries.is_boundary_node(&"execute_transport".into())); + + // Intermediate nodes should not be boundaries + assert!(!boundaries.is_boundary_node(&"list_files".into())); + assert!(!boundaries.is_boundary_node(&"filter_files".into())); + assert!(!boundaries.is_boundary_node(&"read_files".into())); + assert!(!boundaries.is_boundary_node(&"render_markdown".into())); + assert!(!boundaries.is_boundary_node(&"prepare_gist_request".into())); +} + +/// Test that the gist graph passes the boundary mockable test. +#[test] +fn test_gist_graph_boundary_mockable() { + let dag = build_gist_graph(vec![], false); + let result = assert_boundary_mockable(&dag, default_mocks()); + + assert!( + result.is_ok(), + "Gist graph should be boundary-mockable: {:?}", + result.error + ); + assert_eq!(result.boundary_nodes, vec!["execute_transport"]); +} + +/// Test that real mode does NOT intercept boundaries. +#[test] +fn test_real_mode_no_interception() { + let dag = build_gist_graph(vec![], false); + + // Real mode - note: this will fail at execute_transport if gh isn't authenticated, + // but we can still verify that intermediate nodes executed without interception + match execute_with_mode(&dag, ExecutionMode::Real) { + Ok(log) => { + // If it succeeded, verify no interception happened + for entry in &log.entries { + if entry.node_id != "execute_transport" { + assert!( + !entry.was_intercepted, + "{} should not be intercepted", + entry.node_id + ); + } + } + } + Err(_) => { + // Expected to fail at execute_transport without gh auth + // That's fine - the point is we got there without interception + } + } +} diff --git a/crates/gunbc-gistgen/Cargo.toml b/crates/gunbc-gistgen/Cargo.toml deleted file mode 100644 index eb5926110d6..00000000000 --- a/crates/gunbc-gistgen/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "gunbc-gistgen" -version = "0.1.0" -edition = "2021" - -[dependencies] -gunbc-ir = { path = "../gunbc-ir" } -gunbc-validate = { path = "../gunbc-validate" } -gunbc-exec = { path = "../gunbc-exec" } -clap = { version = "4", features = ["derive"] } -ignore = "0.4" -globset = "0.4" - -[dev-dependencies] -tempfile = "3" diff --git a/crates/gunbc-gistgen/src/graph.rs b/crates/gunbc-gistgen/src/graph.rs deleted file mode 100644 index 380fbbf22a0..00000000000 --- a/crates/gunbc-gistgen/src/graph.rs +++ /dev/null @@ -1,214 +0,0 @@ -use gunbc_ir::*; -use gunbc_ir::metadata::NodeMetadata; -use gunbc_ir::types::{BehaviorKind, Idempotency, PatternDecision, ToolId}; - -use crate::ops::GistgenOp; - -fn port(name: &str, ty: &str) -> Port { - Port { - name: PortName(name.into()), - type_id: TypeId(ty.into()), - guard: None, - } -} - -fn guarded_port(name: &str, ty: &str, guard: &str) -> Port { - Port { - name: PortName(name.into()), - type_id: TypeId(ty.into()), - guard: Some(guard.into()), - } -} - -fn edge(from: &str, from_port: &str, to: &str, to_port: &str) -> Edge { - Edge { - from_node: NodeId(from.into()), - from_port: PortName(from_port.into()), - to_node: NodeId(to.into()), - to_port: PortName(to_port.into()), - } -} - -fn meta(tool: &str, behavior: BehaviorKind) -> NodeMetadata { - NodeMetadata { - tool: ToolId(tool.into()), - behavior, - } -} - -/// Build the auth sub-DAG with check → create → resolve diamond. -fn build_auth_subdag() -> Dag { - let nodes = vec![ - Node { - id: NodeId("auth_check".into()), - inputs: vec![], - outputs: vec![port("token", "Secret"), port("needs_create", "Bool")], - metadata: meta("auth", BehaviorKind::Observe), - body: NodeBody::Opaque(GistgenOp::AuthCheck), - }, - Node { - id: NodeId("auth_create".into()), - inputs: vec![guarded_port("needs_create", "Bool", "needs_create == true")], - outputs: vec![port("token", "Secret")], - metadata: meta("auth", BehaviorKind::WritesWorld(Idempotency::Idempotent)), - body: NodeBody::Opaque(GistgenOp::AuthCreate), - }, - Node { - id: NodeId("auth_resolve".into()), - inputs: vec![port("check_token", "Secret"), port("create_token", "Secret")], - outputs: vec![port("token", "Secret")], - metadata: meta("auth", BehaviorKind::Pure), - body: NodeBody::Opaque(GistgenOp::AuthResolve), - }, - ]; - - let edges = vec![ - edge("auth_check", "token", "auth_resolve", "check_token"), - edge("auth_check", "needs_create", "auth_create", "needs_create"), - edge("auth_create", "token", "auth_resolve", "create_token"), - ]; - - let metadata = DagMetadata { - pattern_decisions: vec![PatternDecisionEntry { - tool: ToolId("auth".into()), - pattern: "upsert".into(), - decision: PatternDecision::Instantiated, - }], - export_node: Some(NodeId("auth_resolve".into())), - }; - - Dag { nodes, edges, metadata } -} - -pub fn build_gistgen_dag(repo_path: &str, glob: &str, dry_run: bool) -> Dag { - let upload_behavior = if dry_run { - BehaviorKind::Observe - } else { - BehaviorKind::WritesWorld(Idempotency::NotIdempotent) - }; - - let nodes = vec![ - Node { - id: NodeId("context".into()), - inputs: vec![], - outputs: vec![port("repo", "String"), port("selection_spec", "String")], - metadata: meta("gistgen", BehaviorKind::Observe), - body: NodeBody::Opaque(GistgenOp::Context { - repo_path: repo_path.into(), - glob_pattern: glob.into(), - }), - }, - Node { - id: NodeId("auth".into()), - inputs: vec![], - outputs: vec![port("token", "Secret")], - metadata: meta("auth", BehaviorKind::WritesWorld(Idempotency::Idempotent)), - body: NodeBody::SubDag(build_auth_subdag()), - }, - Node { - id: NodeId("enumerate_files".into()), - inputs: vec![port("repo", "String")], - outputs: vec![port("files", "StrList")], - metadata: meta("gistgen", BehaviorKind::Observe), - body: NodeBody::Opaque(GistgenOp::EnumerateFiles), - }, - Node { - id: NodeId("filter_files".into()), - inputs: vec![port("files", "StrList"), port("selection_spec", "String")], - outputs: vec![port("files", "StrList")], - metadata: meta("gistgen", BehaviorKind::Pure), - body: NodeBody::Opaque(GistgenOp::FilterFiles), - }, - Node { - id: NodeId("read_files".into()), - inputs: vec![port("files", "StrList")], - outputs: vec![port("contents", "MapStrStr")], - metadata: meta("gistgen", BehaviorKind::Observe), - body: NodeBody::Opaque(GistgenOp::ReadFiles), - }, - Node { - id: NodeId("compose_snapshot".into()), - inputs: vec![port("contents", "MapStrStr")], - outputs: vec![port("snapshot", "String")], - metadata: meta("gistgen", BehaviorKind::Pure), - body: NodeBody::Opaque(GistgenOp::ComposeSnapshot), - }, - Node { - id: NodeId("upload_gist".into()), - inputs: vec![port("snapshot", "String"), port("token", "Secret")], - outputs: vec![port("gist_url", "String")], - metadata: meta("gistgen", upload_behavior), - body: NodeBody::Opaque(GistgenOp::UploadGist { dry_run }), - }, - ]; - - let edges = vec![ - edge("context", "repo", "enumerate_files", "repo"), - edge("context", "selection_spec", "filter_files", "selection_spec"), - edge("auth", "token", "upload_gist", "token"), - edge("enumerate_files", "files", "filter_files", "files"), - edge("filter_files", "files", "read_files", "files"), - edge("read_files", "contents", "compose_snapshot", "contents"), - edge("compose_snapshot", "snapshot", "upload_gist", "snapshot"), - ]; - - let metadata = DagMetadata { - pattern_decisions: vec![ - PatternDecisionEntry { - tool: ToolId("auth".into()), - pattern: "upsert".into(), - decision: PatternDecision::Instantiated, - }, - PatternDecisionEntry { - tool: ToolId("gistgen".into()), - pattern: "upsert".into(), - decision: PatternDecision::NotApplicable { - reason: "gistgen is an Emit tool, not Upsert".into(), - }, - }, - ], - export_node: None, - }; - - Dag { nodes, edges, metadata } -} - -#[cfg(test)] -mod tests { - use super::*; - use gunbc_exec::Value; - - #[test] - fn dag_validates() { - let dag = build_gistgen_dag(".", "**/*.rs", true); - assert!(gunbc_validate::validate(&dag).is_ok()); - } - - #[test] - fn dag_executes_dry_run() { - let dag = build_gistgen_dag(".", "**/*.rs", true); - let log = gunbc_exec::execute(&dag).unwrap(); - assert!(!log.entries.is_empty()); - let last = log.entries.last().unwrap(); - assert_eq!(last.node_id, "upload_gist"); - if let Some(Value::Str(url)) = last.outputs.get("gist_url") { - assert!(url.contains("dry-run")); - } else { - panic!("expected gist_url in upload_gist outputs"); - } - } - - #[test] - fn dry_run_upload_is_observe() { - let dag = build_gistgen_dag(".", "**/*", true); - let upload = dag.nodes.iter().find(|n| n.id.0 == "upload_gist").unwrap(); - assert_eq!(upload.metadata.behavior, BehaviorKind::Observe); - } - - #[test] - fn real_upload_is_writes_world() { - let dag = build_gistgen_dag(".", "**/*", false); - let upload = dag.nodes.iter().find(|n| n.id.0 == "upload_gist").unwrap(); - assert_eq!(upload.metadata.behavior, BehaviorKind::WritesWorld(Idempotency::NotIdempotent)); - } -} diff --git a/crates/gunbc-gistgen/src/main.rs b/crates/gunbc-gistgen/src/main.rs deleted file mode 100644 index 635ae3bbe6b..00000000000 --- a/crates/gunbc-gistgen/src/main.rs +++ /dev/null @@ -1,47 +0,0 @@ -mod ops; -mod graph; - -use clap::Parser; - -#[derive(Parser, Debug)] -#[command(name = "gunbc-gistgen", about = "Generate a GitHub Gist from repository files")] -struct Cli { - /// Path to the repository root - #[arg(default_value = ".")] - path: String, - - /// Glob pattern for file selection - #[arg(long, default_value = "**/*")] - glob: String, - - /// Print what would be uploaded without actually creating a gist - #[arg(long)] - dry_run: bool, -} - -fn main() { - let cli = Cli::parse(); - let dag = graph::build_gistgen_dag(&cli.path, &cli.glob, cli.dry_run); - - // Validate - if let Err(errors) = gunbc_validate::validate(&dag) { - eprintln!("Validation failed:"); - for e in &errors { - eprintln!(" - {e}"); - } - std::process::exit(1); - } - eprintln!("DAG validated successfully ({} nodes, {} edges)", dag.nodes.len(), dag.edges.len()); - - // Execute - match gunbc_exec::execute(&dag) { - Ok(log) => { - eprintln!("\nExecution log:"); - eprint!("{log}"); - } - Err(e) => { - eprintln!("Execution failed: {e}"); - std::process::exit(1); - } - } -} diff --git a/crates/gunbc-gistgen/src/ops.rs b/crates/gunbc-gistgen/src/ops.rs deleted file mode 100644 index 7c4beb8f0cd..00000000000 --- a/crates/gunbc-gistgen/src/ops.rs +++ /dev/null @@ -1,352 +0,0 @@ -use std::collections::{BTreeMap, HashMap}; -use std::path::Path; -use std::process::Command; - -use gunbc_exec::{ExecError, Executable, Value}; - -/// The operation type for gistgen nodes. -#[derive(Debug, Clone)] -pub enum GistgenOp { - Context { - repo_path: String, - glob_pattern: String, - }, - AuthCheck, - AuthCreate, - AuthResolve, - EnumerateFiles, - FilterFiles, - ReadFiles, - ComposeSnapshot, - UploadGist { - dry_run: bool, - }, -} - -impl Executable for GistgenOp { - fn execute(&self, inputs: HashMap) -> Result, ExecError> { - match self { - GistgenOp::Context { repo_path, glob_pattern } => { - let mut out = HashMap::new(); - let abs_path = std::fs::canonicalize(repo_path) - .unwrap_or_else(|_| std::path::PathBuf::from(repo_path)); - out.insert("repo".into(), Value::Str(abs_path.to_string_lossy().into_owned())); - out.insert("selection_spec".into(), Value::Str(glob_pattern.clone())); - Ok(out) - } - - GistgenOp::AuthCheck => { - let mut out = HashMap::new(); - // Try GITHUB_TOKEN env var first - if let Ok(token) = std::env::var("GITHUB_TOKEN") { - if !token.is_empty() { - out.insert("token".into(), Value::Secret(gunbc_ir::Secret(token))); - out.insert("needs_create".into(), Value::Bool(false)); - return Ok(out); - } - } - // Fall back to `gh auth token` - match Command::new("gh").args(["auth", "token"]).output() { - Ok(output) if output.status.success() => { - let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !token.is_empty() { - out.insert("token".into(), Value::Secret(gunbc_ir::Secret(token))); - out.insert("needs_create".into(), Value::Bool(false)); - return Ok(out); - } - } - _ => {} - } - // No token found - out.insert("token".into(), Value::Skipped); - out.insert("needs_create".into(), Value::Bool(true)); - Ok(out) - } - - GistgenOp::AuthCreate => { - eprintln!("No GitHub token found. Please authenticate with `gh auth login`."); - let login_status = Command::new("gh") - .args(["auth", "login"]) - .status() - .map_err(|e| ExecError(format!("Failed to run gh auth login: {e}")))?; - - if !login_status.success() { - return Err(ExecError("gh auth login failed".into())); - } - - let output = Command::new("gh") - .args(["auth", "token"]) - .output() - .map_err(|e| ExecError(format!("Failed to run gh auth token: {e}")))?; - - if !output.status.success() { - return Err(ExecError("gh auth token failed after login".into())); - } - - let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let mut out = HashMap::new(); - out.insert("token".into(), Value::Secret(gunbc_ir::Secret(token))); - Ok(out) - } - - GistgenOp::AuthResolve => { - let token = inputs.get("check_token") - .filter(|v| !matches!(v, Value::Skipped)) - .or_else(|| inputs.get("create_token").filter(|v| !matches!(v, Value::Skipped))) - .cloned() - .unwrap_or(Value::Unit); - let mut out = HashMap::new(); - out.insert("token".into(), token); - Ok(out) - } - - GistgenOp::EnumerateFiles => { - let repo = inputs.get("repo") - .and_then(|v| if let Value::Str(s) = v { Some(s.clone()) } else { None }) - .unwrap_or_else(|| ".".into()); - - let mut files = Vec::new(); - let walker = ignore::WalkBuilder::new(&repo) - .hidden(false) - .git_ignore(true) - .git_global(true) - .git_exclude(true) - .build(); - - for entry in walker { - match entry { - Ok(e) if e.file_type().map_or(false, |ft| ft.is_file()) => { - files.push(e.path().to_string_lossy().into_owned()); - } - _ => {} - } - } - files.sort(); - let mut out = HashMap::new(); - out.insert("files".into(), Value::StrList(files)); - Ok(out) - } - - GistgenOp::FilterFiles => { - let file_list = inputs.get("files") - .and_then(|v| if let Value::StrList(v) = v { Some(v.clone()) } else { None }) - .unwrap_or_default(); - let spec = inputs.get("selection_spec") - .and_then(|v| if let Value::Str(s) = v { Some(s.clone()) } else { None }) - .unwrap_or_else(|| "**/*".into()); - - let glob = globset::Glob::new(&spec) - .map_err(|e| ExecError(format!("Invalid glob pattern '{spec}': {e}")))? - .compile_matcher(); - - let filtered: Vec = file_list.into_iter() - .filter(|line| { - let p = Path::new(line); - glob.is_match(p) || glob.is_match(p.file_name().unwrap_or_default()) - }) - .collect(); - - let mut out = HashMap::new(); - out.insert("files".into(), Value::StrList(filtered)); - Ok(out) - } - - GistgenOp::ReadFiles => { - let file_list = inputs.get("files") - .and_then(|v| if let Value::StrList(v) = v { Some(v.clone()) } else { None }) - .unwrap_or_default(); - - let mut contents = BTreeMap::new(); - for path in &file_list { - if path.is_empty() { - continue; - } - match std::fs::read_to_string(path) { - Ok(content) => { - contents.insert(path.clone(), content); - } - Err(e) => { - contents.insert(path.clone(), format!("[error reading file: {e}]")); - } - } - } - let mut out = HashMap::new(); - out.insert("contents".into(), Value::MapStrStr(contents)); - Ok(out) - } - - GistgenOp::ComposeSnapshot => { - let contents = inputs.get("contents") - .and_then(|v| if let Value::MapStrStr(m) = v { Some(m.clone()) } else { None }) - .unwrap_or_default(); - - let mut snapshot = String::from("# Gist Snapshot\n\n"); - for (path, content) in &contents { - snapshot.push_str(&format!("--- {path} ---\n{content}\n\n")); - } - let mut out = HashMap::new(); - out.insert("snapshot".into(), Value::Str(snapshot)); - Ok(out) - } - - GistgenOp::UploadGist { dry_run } => { - let snapshot = inputs.get("snapshot") - .and_then(|v| if let Value::Str(s) = v { Some(s.clone()) } else { None }) - .unwrap_or_default(); - let token_display = match inputs.get("token") { - Some(Value::Secret(_)) => "".to_string(), - Some(v) => format!("{v}"), - None => "none".into(), - }; - - if *dry_run { - eprintln!("[DRY RUN] Would upload gist:"); - eprintln!(" Token: {token_display}"); - eprintln!(" Snapshot length: {} bytes", snapshot.len()); - if !snapshot.is_empty() { - eprintln!(" Preview: {}...", &snapshot[..snapshot.len().min(200)]); - } - let mut out = HashMap::new(); - out.insert("gist_url".into(), Value::Str("https://gist.github.com/dry-run/preview".into())); - return Ok(out); - } - - // Real upload via `gh gist create -` - let token = match inputs.get("token") { - Some(Value::Secret(s)) => s.as_inner().clone(), - _ => return Err(ExecError("missing or invalid token for gist upload".into())), - }; - eprintln!("Uploading gist ({} bytes)...", snapshot.len()); - let mut child = Command::new("gh") - .args(["gist", "create", "-"]) - .env("GH_TOKEN", &token) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .map_err(|e| ExecError(format!("Failed to spawn gh: {e}")))?; - - use std::io::Write; - if let Some(mut stdin) = child.stdin.take() { - stdin.write_all(snapshot.as_bytes()) - .map_err(|e| ExecError(format!("Failed to write to gh stdin: {e}")))?; - } - - let output = child.wait_with_output() - .map_err(|e| ExecError(format!("Failed to wait for gh: {e}")))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(ExecError(format!("gh gist create failed: {stderr}"))); - } - - let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); - eprintln!("Gist created: {url}"); - let mut out = HashMap::new(); - out.insert("gist_url".into(), Value::Str(url)); - Ok(out) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn enumerate_finds_files() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("a.rs"), "fn main() {}").unwrap(); - std::fs::write(dir.path().join("b.txt"), "hello").unwrap(); - - let mut inputs = HashMap::new(); - inputs.insert("repo".into(), Value::Str(dir.path().to_string_lossy().into_owned())); - - let op = GistgenOp::EnumerateFiles; - let out = op.execute(inputs).unwrap(); - if let Value::StrList(files) = &out["files"] { - let joined = files.join("\n"); - assert!(joined.contains("a.rs")); - assert!(joined.contains("b.txt")); - } else { - panic!("expected StrList"); - } - } - - #[test] - fn filter_respects_glob() { - let mut inputs = HashMap::new(); - inputs.insert("files".into(), Value::StrList(vec![ - "src/main.rs".into(), "src/lib.rs".into(), "README.md".into(), - ])); - inputs.insert("selection_spec".into(), Value::Str("**/*.rs".into())); - - let op = GistgenOp::FilterFiles; - let out = op.execute(inputs).unwrap(); - if let Value::StrList(files) = &out["files"] { - assert_eq!(files.len(), 2); - assert!(files.iter().any(|f| f.contains("main.rs"))); - assert!(files.iter().any(|f| f.contains("lib.rs"))); - } else { - panic!("expected StrList"); - } - } - - #[test] - fn read_files_returns_map() { - let dir = tempfile::tempdir().unwrap(); - let file_path = dir.path().join("test.txt"); - std::fs::write(&file_path, "hello world").unwrap(); - - let mut inputs = HashMap::new(); - inputs.insert("files".into(), Value::StrList(vec![ - file_path.to_string_lossy().into_owned(), - ])); - - let op = GistgenOp::ReadFiles; - let out = op.execute(inputs).unwrap(); - if let Value::MapStrStr(map) = &out["contents"] { - assert_eq!(map.len(), 1); - assert_eq!(map.values().next().unwrap(), "hello world"); - } else { - panic!("expected MapStrStr"); - } - } - - #[test] - fn auth_check_reads_env() { - std::env::set_var("GITHUB_TOKEN", "test_token_abc"); - let op = GistgenOp::AuthCheck; - let out = op.execute(HashMap::new()).unwrap(); - assert!(matches!(out.get("token"), Some(Value::Secret(_)))); - assert!(matches!(out.get("needs_create"), Some(Value::Bool(false)))); - std::env::remove_var("GITHUB_TOKEN"); - } - - #[test] - fn dry_run_does_not_call_gh() { - let mut inputs = HashMap::new(); - inputs.insert("snapshot".into(), Value::Str("test snapshot".into())); - inputs.insert("token".into(), Value::Secret(gunbc_ir::Secret("tok".into()))); - - let op = GistgenOp::UploadGist { dry_run: true }; - let out = op.execute(inputs).unwrap(); - if let Value::Str(url) = &out["gist_url"] { - assert!(url.contains("dry-run")); - } else { - panic!("expected Str"); - } - } - - #[test] - fn auth_resolve_picks_non_skipped() { - let mut inputs = HashMap::new(); - inputs.insert("check_token".into(), Value::Skipped); - inputs.insert("create_token".into(), Value::Secret(gunbc_ir::Secret("real_tok".into()))); - - let op = GistgenOp::AuthResolve; - let out = op.execute(inputs).unwrap(); - assert!(matches!(out.get("token"), Some(Value::Secret(_)))); - } -} diff --git a/crates/gunbc-ir/Cargo.toml b/crates/gunbc-ir/Cargo.toml index a6ae86dfd8d..935db95b656 100644 --- a/crates/gunbc-ir/Cargo.toml +++ b/crates/gunbc-ir/Cargo.toml @@ -1,4 +1,10 @@ [package] name = "gunbc-ir" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Core IR types for gunbc: Node, Dag, Edge, Port, and boundary detection" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/gunbc-ir/src/boundary.rs b/crates/gunbc-ir/src/boundary.rs new file mode 100644 index 00000000000..3bb621578cb --- /dev/null +++ b/crates/gunbc-ir/src/boundary.rs @@ -0,0 +1,174 @@ +//! Boundary detection: find outputs that leave the DAG (world writes). +//! +//! The core insight: **unconnected output ports are boundaries**. +//! Data leaving the DAG necessarily goes to the world. + +use crate::dag::Dag; +use crate::types::{NodeId, PortName}; +use std::collections::HashSet; + +/// Information about DAG boundaries. +#[derive(Debug, Clone, Default)] +pub struct BoundaryInfo { + /// Nodes that have at least one boundary output + pub boundary_nodes: Vec, + /// Specific (node, port) pairs that are boundaries + pub boundary_ports: Vec<(NodeId, PortName)>, +} + +impl BoundaryInfo { + /// Check if a node is a boundary node. + pub fn is_boundary_node(&self, node_id: &NodeId) -> bool { + self.boundary_nodes.iter().any(|n| n == node_id) + } + + /// Check if a specific port is a boundary. + pub fn is_boundary_port(&self, node_id: &NodeId, port_name: &PortName) -> bool { + self.boundary_ports + .iter() + .any(|(n, p)| n == node_id && p == port_name) + } + + /// Get all boundary ports for a specific node. + pub fn ports_for_node(&self, node_id: &NodeId) -> Vec<&PortName> { + self.boundary_ports + .iter() + .filter(|(n, _)| n == node_id) + .map(|(_, p)| p) + .collect() + } +} + +/// Detect boundaries in a DAG. +/// +/// A boundary is an output port that has no downstream edge — +/// data leaving this port exits the DAG universe and necessarily +/// goes to the world. +/// +/// # Example +/// +/// ``` +/// use gunbc_ir::{Dag, Node, Port, Edge, NodeBody, detect_boundaries}; +/// +/// let mut dag: Dag<()> = Dag::new(); +/// +/// // Node A outputs to Node B (connected) +/// dag.add_node(Node::opaque("A", vec![], vec![Port::new("out", "String")], ())); +/// dag.add_node(Node::opaque("B", vec![Port::new("in", "String")], vec![Port::new("result", "String")], ())); +/// dag.add_edge(Edge::new("A", "out", "B", "in")); +/// +/// let boundaries = detect_boundaries(&dag); +/// +/// // B's "result" port is unconnected — it's a boundary +/// assert!(boundaries.is_boundary_port(&"B".into(), &"result".into())); +/// // A's "out" port is connected — not a boundary +/// assert!(!boundaries.is_boundary_port(&"A".into(), &"out".into())); +/// ``` +pub fn detect_boundaries(dag: &Dag) -> BoundaryInfo { + // Collect all (from_node, from_port) pairs that are sources of edges + let connected: HashSet<(NodeId, PortName)> = dag + .edges + .iter() + .map(|e| (e.from_node.clone(), e.from_port.clone())) + .collect(); + + // Find all output ports that are NOT connected + let boundary_ports: Vec<(NodeId, PortName)> = dag + .nodes + .iter() + .flat_map(|n| { + n.outputs + .iter() + .map(|p| (n.id.clone(), p.name.clone())) + .filter(|port| !connected.contains(port)) + }) + .collect(); + + // Derive unique boundary nodes + let mut boundary_nodes: Vec = boundary_ports + .iter() + .map(|(n, _)| n.clone()) + .collect::>() + .into_iter() + .collect(); + boundary_nodes.sort_by(|a, b| a.0.cmp(&b.0)); + + BoundaryInfo { + boundary_nodes, + boundary_ports, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dag::build::{edge, port}; + use crate::node::Node; + + #[test] + fn test_single_node_all_outputs_are_boundaries() { + let mut dag: Dag<()> = Dag::new(); + dag.add_node(Node::opaque( + "single", + vec![], + vec![port("out1", "String"), port("out2", "Int")], + (), + )); + + let boundaries = detect_boundaries(&dag); + + assert_eq!(boundaries.boundary_nodes.len(), 1); + assert_eq!(boundaries.boundary_ports.len(), 2); + assert!(boundaries.is_boundary_port(&"single".into(), &"out1".into())); + assert!(boundaries.is_boundary_port(&"single".into(), &"out2".into())); + } + + #[test] + fn test_connected_port_not_boundary() { + let mut dag: Dag<()> = Dag::new(); + dag.add_node(Node::opaque("A", vec![], vec![port("out", "String")], ())); + dag.add_node(Node::opaque( + "B", + vec![port("in", "String")], + vec![port("result", "String")], + (), + )); + dag.add_edge(edge("A", "out", "B", "in")); + + let boundaries = detect_boundaries(&dag); + + // A.out is connected -> not a boundary + assert!(!boundaries.is_boundary_port(&"A".into(), &"out".into())); + // B.result is unconnected -> is a boundary + assert!(boundaries.is_boundary_port(&"B".into(), &"result".into())); + // Only B is a boundary node + assert_eq!(boundaries.boundary_nodes.len(), 1); + assert!(boundaries.is_boundary_node(&"B".into())); + } + + #[test] + fn test_pipeline_only_last_is_boundary() { + let mut dag: Dag<()> = Dag::new(); + dag.add_node(Node::opaque("A", vec![], vec![port("out", "S")], ())); + dag.add_node(Node::opaque( + "B", + vec![port("in", "S")], + vec![port("out", "S")], + (), + )); + dag.add_node(Node::opaque( + "C", + vec![port("in", "S")], + vec![port("out", "S")], + (), + )); + dag.add_edge(edge("A", "out", "B", "in")); + dag.add_edge(edge("B", "out", "C", "in")); + + let boundaries = detect_boundaries(&dag); + + assert_eq!(boundaries.boundary_nodes.len(), 1); + assert!(boundaries.is_boundary_node(&"C".into())); + assert!(boundaries.is_boundary_port(&"C".into(), &"out".into())); + } +} diff --git a/crates/gunbc-ir/src/dag.rs b/crates/gunbc-ir/src/dag.rs index aa8cfe44f41..b559ac4521c 100644 --- a/crates/gunbc-ir/src/dag.rs +++ b/crates/gunbc-ir/src/dag.rs @@ -1,8 +1,51 @@ +//! DAG structure: edges, ports, and the graph itself. + use crate::node::Node; -use crate::types::{NodeId, PatternDecision, PortName, ToolId, TypeId}; +use crate::types::{Cardinality, NodeId, PortName, TypeId}; +use crate::value::Value; +use serde::{Deserialize, Serialize}; + +/// A directed acyclic graph of nodes. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Dag { + /// Nodes in the DAG + pub nodes: Vec>, + /// Edges connecting output ports to input ports + pub edges: Vec, +} + +impl Dag { + /// Create an empty DAG. + pub fn new() -> Self { + Self { + nodes: Vec::new(), + edges: Vec::new(), + } + } + + /// Add a node to the DAG. + pub fn add_node(&mut self, node: Node) { + self.nodes.push(node); + } + + /// Add an edge to the DAG. + pub fn add_edge(&mut self, edge: Edge) { + self.edges.push(edge); + } + + /// Get a node by ID. + pub fn get_node(&self, id: &NodeId) -> Option<&Node> { + self.nodes.iter().find(|n| &n.id == id) + } + + /// Get a mutable reference to a node by ID. + pub fn get_node_mut(&mut self, id: &NodeId) -> Option<&mut Node> { + self.nodes.iter_mut().find(|n| &n.id == id) + } +} /// An edge connecting an output port of one node to an input port of another. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Edge { pub from_node: NodeId, pub from_port: PortName, @@ -10,36 +53,197 @@ pub struct Edge { pub to_port: PortName, } -/// A directed acyclic graph of nodes. -#[derive(Debug, Clone)] -pub struct Dag { - pub nodes: Vec>, - pub edges: Vec, - pub metadata: DagMetadata, +impl Edge { + pub fn new( + from_node: impl Into, + from_port: impl Into, + to_node: impl Into, + to_port: impl Into, + ) -> Self { + Self { + from_node: from_node.into(), + from_port: from_port.into(), + to_node: to_node.into(), + to_port: to_port.into(), + } + } } -/// Port definition on a node. -#[derive(Debug, Clone)] +/// A port on a node (input or output). +/// +/// Every port has a cardinality that describes how many values can flow through it. +/// This enables semantic test generation and runtime validation. +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Port { + /// Name of the port pub name: PortName, + /// Type of data flowing through this port pub type_id: TypeId, - /// Optional guard expression — if present and evaluates false, node produces Skipped. - pub guard: Option, + /// Set-theoretic cardinality (how many values) + pub cardinality: Cardinality, + /// Optional guard predicate (for input ports) + pub guard: Option, } -/// DAG-level metadata. Pattern decisions live here, keyed by tool, not repeated per node. -#[derive(Debug, Clone, Default)] -pub struct DagMetadata { - pub pattern_decisions: Vec, - /// If set, the executor uses this node's outputs as the SubDag result - /// instead of the last log entry. - pub export_node: Option, +impl Port { + /// Create a new port without a guard. + /// Defaults to `Cardinality::One` (scalar, required). + pub fn new(name: impl Into, type_id: impl Into) -> Self { + Self { + name: name.into(), + type_id: type_id.into(), + cardinality: Cardinality::One, + guard: None, + } + } + + /// Create a port with explicit cardinality. + pub fn with_cardinality( + name: impl Into, + type_id: impl Into, + cardinality: Cardinality, + ) -> Self { + Self { + name: name.into(), + type_id: type_id.into(), + cardinality, + guard: None, + } + } + + /// Create a scalar port (exactly one value, required). + /// This is the most common case for simple data flow. + pub fn scalar(name: impl Into, type_id: impl Into) -> Self { + Self::with_cardinality(name, type_id, Cardinality::One) + } + + /// Create an optional port (zero or one value). + /// Use for nullable or optional data. + pub fn optional(name: impl Into, type_id: impl Into) -> Self { + Self::with_cardinality(name, type_id, Cardinality::ZeroOrOne) + } + + /// Create a list port (zero or more values). + /// Use for collections that may be empty. + pub fn list(name: impl Into, type_id: impl Into) -> Self { + Self::with_cardinality(name, type_id, Cardinality::ZeroOrMore) + } + + /// Create a non-empty list port (one or more values). + /// Use for collections that must have at least one element. + pub fn non_empty_list(name: impl Into, type_id: impl Into) -> Self { + Self::with_cardinality(name, type_id, Cardinality::OneOrMore) + } + + /// Create a void port (zero values). + /// Use for signals that carry no data, just timing. + pub fn void(name: impl Into) -> Self { + Self::with_cardinality(name, "Unit", Cardinality::Zero) + } + + /// Create a port with an equality guard. + pub fn guarded(name: impl Into, type_id: impl Into, expected: Value) -> Self { + Self { + name: name.into(), + type_id: type_id.into(), + cardinality: Cardinality::One, + guard: Some(Guard::Eq(expected)), + } + } + + /// Create a port with a guard and explicit cardinality. + pub fn guarded_with_cardinality( + name: impl Into, + type_id: impl Into, + cardinality: Cardinality, + guard: Guard, + ) -> Self { + Self { + name: name.into(), + type_id: type_id.into(), + cardinality, + guard: Some(guard), + } + } } -/// Records that a tool was evaluated against a pattern and a decision was made. -#[derive(Debug, Clone)] -pub struct PatternDecisionEntry { - pub tool: ToolId, - pub pattern: String, - pub decision: PatternDecision, +/// Guard predicate for conditional execution. +/// +/// If a guard evaluates to false, the node is skipped and outputs `Skipped`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Guard { + /// Value must equal expected + Eq(Value), + /// Value must not equal expected + NotEq(Value), +} + +impl Guard { + /// Evaluate the guard against an actual value. + pub fn evaluate(&self, actual: &Value) -> bool { + match self { + Guard::Eq(expected) => values_equal(actual, expected), + Guard::NotEq(expected) => !values_equal(actual, expected), + } + } +} + +/// Compare two values for equality (structural). +fn values_equal(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Unit, Value::Unit) => true, + (Value::Bool(a), Value::Bool(b)) => a == b, + (Value::Str(a), Value::Str(b)) => a == b, + (Value::Int(a), Value::Int(b)) => a == b, + (Value::StrList(a), Value::StrList(b)) => a == b, + (Value::MapStrStr(a), Value::MapStrStr(b)) => a == b, + (Value::Json(a), Value::Json(b)) => a == b, + (Value::Skipped, Value::Skipped) => true, + _ => false, + } +} + +/// Helper functions for building DAGs. +pub mod build { + use super::*; + + /// Create a simple port (defaults to Cardinality::One). + pub fn port(name: &str, type_id: &str) -> Port { + Port::new(name, type_id) + } + + /// Create a scalar port (exactly one value). + pub fn scalar(name: &str, type_id: &str) -> Port { + Port::scalar(name, type_id) + } + + /// Create an optional port (zero or one value). + pub fn optional(name: &str, type_id: &str) -> Port { + Port::optional(name, type_id) + } + + /// Create a list port (zero or more values). + pub fn list(name: &str, type_id: &str) -> Port { + Port::list(name, type_id) + } + + /// Create a non-empty list port (one or more values). + pub fn non_empty_list(name: &str, type_id: &str) -> Port { + Port::non_empty_list(name, type_id) + } + + /// Create a void port (zero values, signal only). + pub fn void(name: &str) -> Port { + Port::void(name) + } + + /// Create a guarded port with equality check. + pub fn guarded_port(name: &str, type_id: &str, expected: Value) -> Port { + Port::guarded(name, type_id, expected) + } + + /// Create an edge. + pub fn edge(from_node: &str, from_port: &str, to_node: &str, to_port: &str) -> Edge { + Edge::new(from_node, from_port, to_node, to_port) + } } diff --git a/crates/gunbc-ir/src/entrypoint.rs b/crates/gunbc-ir/src/entrypoint.rs new file mode 100644 index 00000000000..b8696b8ba8c --- /dev/null +++ b/crates/gunbc-ir/src/entrypoint.rs @@ -0,0 +1,222 @@ +//! Entrypoint detection: find inputs that enter the DAG (world reads). +//! +//! The core insight: **unconnected input ports are entrypoints**. +//! Data entering the DAG necessarily comes from the world. +//! +//! This is symmetric to boundary detection: +//! - Boundaries = outputs with no downstream edge = world writes +//! - Entrypoints = inputs with no upstream edge = world reads + +use crate::dag::Dag; +use crate::types::{NodeId, PortName, TypeId}; +use std::collections::HashSet; + +/// Information about DAG entrypoints. +#[derive(Debug, Clone, Default)] +pub struct EntrypointInfo { + /// Nodes that have at least one entrypoint input + pub entrypoint_nodes: Vec, + /// Specific (node, port, type) tuples that are entrypoints + pub entrypoint_ports: Vec<(NodeId, PortName, TypeId)>, +} + +impl EntrypointInfo { + /// Check if a node is an entrypoint node. + pub fn is_entrypoint_node(&self, node_id: &NodeId) -> bool { + self.entrypoint_nodes.iter().any(|n| n == node_id) + } + + /// Check if a specific port is an entrypoint. + pub fn is_entrypoint_port(&self, node_id: &NodeId, port_name: &PortName) -> bool { + self.entrypoint_ports + .iter() + .any(|(n, p, _)| n == node_id && p == port_name) + } + + /// Get all entrypoint ports for a specific node. + pub fn ports_for_node(&self, node_id: &NodeId) -> Vec<(&PortName, &TypeId)> { + self.entrypoint_ports + .iter() + .filter(|(n, _, _)| n == node_id) + .map(|(_, p, t)| (p, t)) + .collect() + } + + /// Get all entrypoint ports with their types. + pub fn all_ports(&self) -> Vec<(&NodeId, &PortName, &TypeId)> { + self.entrypoint_ports + .iter() + .map(|(n, p, t)| (n, p, t)) + .collect() + } +} + +/// Detect entrypoints in a DAG. +/// +/// An entrypoint is an input port that has no upstream edge — +/// data entering this port comes from outside the DAG universe, +/// necessarily from the world. +/// +/// # Example +/// +/// ``` +/// use gunbc_ir::{Dag, Node, Port, Edge, detect_entrypoints}; +/// +/// let mut dag: Dag<()> = Dag::new(); +/// +/// // Node A has an input with no upstream (entrypoint) +/// dag.add_node(Node::opaque("A", vec![Port::new("config", "String")], vec![Port::new("out", "String")], ())); +/// dag.add_node(Node::opaque("B", vec![Port::new("in", "String")], vec![Port::new("result", "String")], ())); +/// dag.add_edge(Edge::new("A", "out", "B", "in")); +/// +/// let entrypoints = detect_entrypoints(&dag); +/// +/// // A's "config" port is unconnected — it's an entrypoint +/// assert!(entrypoints.is_entrypoint_port(&"A".into(), &"config".into())); +/// // B's "in" port is connected — not an entrypoint +/// assert!(!entrypoints.is_entrypoint_port(&"B".into(), &"in".into())); +/// ``` +pub fn detect_entrypoints(dag: &Dag) -> EntrypointInfo { + // Collect all (to_node, to_port) pairs that are targets of edges + let connected: HashSet<(NodeId, PortName)> = dag + .edges + .iter() + .map(|e| (e.to_node.clone(), e.to_port.clone())) + .collect(); + + // Find all input ports that are NOT connected + let entrypoint_ports: Vec<(NodeId, PortName, TypeId)> = dag + .nodes + .iter() + .flat_map(|n| { + n.inputs + .iter() + .map(|p| (n.id.clone(), p.name.clone(), p.type_id.clone())) + .filter(|(node_id, port_name, _)| { + !connected.contains(&(node_id.clone(), port_name.clone())) + }) + }) + .collect(); + + // Derive unique entrypoint nodes + let mut entrypoint_nodes: Vec = entrypoint_ports + .iter() + .map(|(n, _, _)| n.clone()) + .collect::>() + .into_iter() + .collect(); + entrypoint_nodes.sort_by(|a, b| a.0.cmp(&b.0)); + + EntrypointInfo { + entrypoint_nodes, + entrypoint_ports, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dag::build::{edge, port}; + use crate::node::Node; + + #[test] + fn test_single_node_all_inputs_are_entrypoints() { + let mut dag: Dag<()> = Dag::new(); + dag.add_node(Node::opaque( + "single", + vec![port("in1", "String"), port("in2", "Int")], + vec![], + (), + )); + + let entrypoints = detect_entrypoints(&dag); + + assert_eq!(entrypoints.entrypoint_nodes.len(), 1); + assert_eq!(entrypoints.entrypoint_ports.len(), 2); + assert!(entrypoints.is_entrypoint_port(&"single".into(), &"in1".into())); + assert!(entrypoints.is_entrypoint_port(&"single".into(), &"in2".into())); + } + + #[test] + fn test_connected_port_not_entrypoint() { + let mut dag: Dag<()> = Dag::new(); + dag.add_node(Node::opaque( + "A", + vec![port("config", "String")], + vec![port("out", "String")], + (), + )); + dag.add_node(Node::opaque( + "B", + vec![port("in", "String")], + vec![port("result", "String")], + (), + )); + dag.add_edge(edge("A", "out", "B", "in")); + + let entrypoints = detect_entrypoints(&dag); + + // A.config is unconnected -> is an entrypoint + assert!(entrypoints.is_entrypoint_port(&"A".into(), &"config".into())); + // B.in is connected -> not an entrypoint + assert!(!entrypoints.is_entrypoint_port(&"B".into(), &"in".into())); + // Only A is an entrypoint node + assert_eq!(entrypoints.entrypoint_nodes.len(), 1); + assert!(entrypoints.is_entrypoint_node(&"A".into())); + } + + #[test] + fn test_pipeline_only_first_is_entrypoint() { + let mut dag: Dag<()> = Dag::new(); + dag.add_node(Node::opaque( + "A", + vec![port("in", "S")], + vec![port("out", "S")], + (), + )); + dag.add_node(Node::opaque( + "B", + vec![port("in", "S")], + vec![port("out", "S")], + (), + )); + dag.add_node(Node::opaque( + "C", + vec![port("in", "S")], + vec![port("out", "S")], + (), + )); + dag.add_edge(edge("A", "out", "B", "in")); + dag.add_edge(edge("B", "out", "C", "in")); + + let entrypoints = detect_entrypoints(&dag); + + assert_eq!(entrypoints.entrypoint_nodes.len(), 1); + assert!(entrypoints.is_entrypoint_node(&"A".into())); + assert!(entrypoints.is_entrypoint_port(&"A".into(), &"in".into())); + } + + #[test] + fn test_entrypoint_includes_type() { + let mut dag: Dag<()> = Dag::new(); + dag.add_node(Node::opaque( + "node", + vec![port("path", "String"), port("count", "Int")], + vec![], + (), + )); + + let entrypoints = detect_entrypoints(&dag); + + // Check that types are captured + let ports = entrypoints.all_ports(); + assert_eq!(ports.len(), 2); + + // Find the path port and check its type + let path_port = ports.iter().find(|(_, p, _)| p.0 == "path").unwrap(); + assert_eq!(path_port.2.0, "String"); + + let count_port = ports.iter().find(|(_, p, _)| p.0 == "count").unwrap(); + assert_eq!(count_port.2.0, "Int"); + } +} diff --git a/crates/gunbc-ir/src/lib.rs b/crates/gunbc-ir/src/lib.rs index 804559c892a..5a8e8149cfb 100644 --- a/crates/gunbc-ir/src/lib.rs +++ b/crates/gunbc-ir/src/lib.rs @@ -1,10 +1,53 @@ -pub mod types; -pub mod metadata; -pub mod node; +//! gunbc-ir: Core IR types for the gunbc DAG framework. +//! +//! This crate provides the fundamental types: +//! - [`Node`]: A node in the DAG (opaque operation or sub-DAG) +//! - [`Dag`]: A directed acyclic graph of nodes +//! - [`Edge`]: Connection between output and input ports +//! - [`Port`]: Input or output port with type and optional guard +//! - [`Value`]: Runtime values flowing through the DAG +//! - [`detect_boundaries`]: Find outputs that leave the DAG (world writes) +//! - [`detect_entrypoints`]: Find inputs that enter the DAG (world reads) +//! - [`transport`]: Transport layer types for I/O abstraction +//! +//! # Core Insight +//! +//! **World I/O is structural, not annotated.** +//! +//! - An output port with no downstream edge is a *boundary* — data leaving +//! that port exits the DAG and goes to the world (world write). +//! - An input port with no upstream edge is an *entrypoint* — data entering +//! that port comes from outside the DAG (world read). +//! +//! These are detected by [`detect_boundaries`] and [`detect_entrypoints`], +//! not by annotations on nodes. +//! +//! # Transport Layer +//! +//! All world I/O can be modeled as transport requests/responses: +//! - REST/HTTP for web APIs +//! - File operations for filesystem I/O +//! - TCP for raw network connections +//! - Shell for command execution +//! +//! This allows business logic to remain pure while transport execution +//! happens at well-defined boundaries. + +pub mod boundary; pub mod dag; +pub mod entrypoint; +pub mod node; +pub mod patterns; +pub mod transport; +pub mod types; +pub mod value; -// Re-export core types at crate root for convenience. -pub use dag::{Dag, DagMetadata, Edge, PatternDecisionEntry, Port}; -pub use metadata::NodeMetadata; +// Re-exports for convenience +pub use boundary::{detect_boundaries, BoundaryInfo}; +pub use dag::{build, Dag, Edge, Guard, Port}; +pub use entrypoint::{detect_entrypoints, EntrypointInfo}; pub use node::{Node, NodeBody}; -pub use types::*; +pub use patterns::{AtomicBuilder, TransactionBuilder, UpsertBuilder}; +pub use transport::{TransportRequest, TransportResponse}; +pub use types::{Cardinality, CardinalityCase, NodeId, PortName, TypeId}; +pub use value::Value; diff --git a/crates/gunbc-ir/src/metadata.rs b/crates/gunbc-ir/src/metadata.rs deleted file mode 100644 index 067284015a6..00000000000 --- a/crates/gunbc-ir/src/metadata.rs +++ /dev/null @@ -1,8 +0,0 @@ -use crate::types::{BehaviorKind, ToolId}; - -/// Metadata attached to every node. -#[derive(Debug, Clone)] -pub struct NodeMetadata { - pub tool: ToolId, - pub behavior: BehaviorKind, -} diff --git a/crates/gunbc-ir/src/node.rs b/crates/gunbc-ir/src/node.rs index dafd704b4fa..6e943d32c69 100644 --- a/crates/gunbc-ir/src/node.rs +++ b/crates/gunbc-ir/src/node.rs @@ -1,20 +1,63 @@ +//! Node types for the DAG. + use crate::dag::{Dag, Port}; -use crate::metadata::NodeMetadata; use crate::types::NodeId; +use serde::{Deserialize, Serialize}; /// A node in the DAG, generic over its operation type. -#[derive(Debug, Clone)] +/// +/// Nodes are pure transformations of inputs to outputs. +/// World-writes are determined structurally by boundary detection, +/// not by node annotations. +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Node { + /// Unique identifier for this node pub id: NodeId, + /// Input ports pub inputs: Vec, + /// Output ports pub outputs: Vec, - pub metadata: NodeMetadata, + /// The node's body: either an opaque operation or a nested sub-DAG pub body: NodeBody, } +impl Node { + /// Create a new opaque node. + pub fn opaque(id: impl Into, inputs: Vec, outputs: Vec, op: T) -> Self { + Self { + id: id.into(), + inputs, + outputs, + body: NodeBody::Opaque(op), + } + } + + /// Create a new sub-DAG node. + pub fn subdag(id: impl Into, inputs: Vec, outputs: Vec, dag: Dag) -> Self { + Self { + id: id.into(), + inputs, + outputs, + body: NodeBody::SubDag(dag), + } + } + + /// Check if this node is opaque (not a sub-DAG). + pub fn is_opaque(&self) -> bool { + matches!(self.body, NodeBody::Opaque(_)) + } + + /// Check if this node is a sub-DAG. + pub fn is_subdag(&self) -> bool { + matches!(self.body, NodeBody::SubDag(_)) + } +} + /// The body of a node: either an opaque operation or a nested sub-DAG. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum NodeBody { + /// An opaque operation — we trust it, don't look inside Opaque(T), + /// A nested sub-DAG — same structure, recursive SubDag(Dag), } diff --git a/crates/gunbc-ir/src/patterns/atomic.rs b/crates/gunbc-ir/src/patterns/atomic.rs new file mode 100644 index 00000000000..660e4b9076d --- /dev/null +++ b/crates/gunbc-ir/src/patterns/atomic.rs @@ -0,0 +1,296 @@ +//! Atomic pattern: Precondition → Operation → Postcondition. +//! +//! The atomic pattern ensures an operation is only executed when preconditions +//! are met, and postconditions are verified after execution: +//! +//! 1. **Precondition**: Verify operation can proceed (optional, guarded) +//! 2. **Operation**: Execute the main operation +//! 3. **Postcondition**: Verify operation succeeded (optional) +//! +//! ```text +//! ┌────────────────────────────────────────────────────┐ +//! │ Atomic │ +//! │ ┌──────────────┐ ┌───────────┐ ┌─────────┐ │ +//! │ │ Precondition │───▶│ Operation │───▶│ Postcon │ │ +//! │ └──────────────┘ └───────────┘ └─────────┘ │ +//! │ │ │ │ │ +//! │ └──── guard ───────┘ │ │ +//! │ (pre_ok=true) ▼ │ +//! │ (verify) │ +//! └────────────────────────────────────────────────────┘ +//! ``` +//! +//! Unlike upsert, the atomic pattern is more general and doesn't assume +//! check-then-create semantics. + +use crate::dag::{Dag, Edge, Guard, Port}; +use crate::node::Node; +use crate::types::Cardinality; +use crate::value::Value; + +/// Builder for the atomic operation pattern. +/// +/// # Type Parameters +/// +/// - `T`: The operation type used in the DAG +/// +/// # Example +/// +/// ```ignore +/// let atomic = AtomicBuilder::new("safe_delete") +/// .with_precondition(FileOp::CheckEmpty) +/// .with_operation(FileOp::Delete) +/// .with_postcondition(FileOp::VerifyDeleted) +/// .build(); +/// ``` +pub struct AtomicBuilder { + name: String, + precondition_op: Option, + operation_op: Option, + postcondition_op: Option, + // Port configurations + input_port_name: String, + input_port_type: String, + output_port_name: String, + output_port_type: String, +} + +impl AtomicBuilder { + /// Create a new atomic builder with the given name. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + precondition_op: None, + operation_op: None, + postcondition_op: None, + input_port_name: "input".to_string(), + input_port_type: "Any".to_string(), + output_port_name: "output".to_string(), + output_port_type: "Any".to_string(), + } + } + + /// Set the precondition operation (optional). + /// + /// The precondition should output `"pre_ok": Bool`. + /// If pre_ok is false, the operation is skipped. + pub fn with_precondition(mut self, op: T) -> Self { + self.precondition_op = Some(op); + self + } + + /// Set the main operation (required). + pub fn with_operation(mut self, op: T) -> Self { + self.operation_op = Some(op); + self + } + + /// Set the postcondition operation (optional). + /// + /// The postcondition verifies the operation succeeded. + pub fn with_postcondition(mut self, op: T) -> Self { + self.postcondition_op = Some(op); + self + } + + /// Configure the input port. + pub fn with_input_port(mut self, name: impl Into, type_id: impl Into) -> Self { + self.input_port_name = name.into(); + self.input_port_type = type_id.into(); + self + } + + /// Configure the output port. + pub fn with_output_port(mut self, name: impl Into, type_id: impl Into) -> Self { + self.output_port_name = name.into(); + self.output_port_type = type_id.into(); + self + } + + /// Build the atomic pattern as a SubDag node. + /// + /// # Panics + /// + /// Panics if the operation is not set. + pub fn build(self) -> Node { + let operation_op = self.operation_op.expect("operation is required"); + + let mut dag = Dag::new(); + + // Precondition node (if provided) + let has_precondition = self.precondition_op.is_some(); + if let Some(precondition_op) = self.precondition_op { + dag.add_node(Node::opaque( + "precondition", + vec![Port::scalar(self.input_port_name.as_str(), self.input_port_type.as_str())], + vec![ + Port::scalar("pre_ok", "Bool"), + Port::scalar(self.input_port_name.as_str(), self.input_port_type.as_str()), + ], + precondition_op, + )); + } + + // Operation node + let operation_inputs = if has_precondition { + vec![ + Port::scalar(self.input_port_name.as_str(), self.input_port_type.as_str()), + Port::guarded_with_cardinality( + "pre_ok", + "Bool", + Cardinality::One, + Guard::Eq(Value::Bool(true)), + ), + ] + } else { + vec![Port::scalar(self.input_port_name.as_str(), self.input_port_type.as_str())] + }; + + dag.add_node(Node::opaque( + "operation", + operation_inputs, + vec![ + Port::scalar(self.output_port_name.as_str(), self.output_port_type.as_str()), + Port::scalar("op_ok", "Bool"), + ], + operation_op, + )); + + // Postcondition node (if provided) + let has_postcondition = self.postcondition_op.is_some(); + if let Some(postcondition_op) = self.postcondition_op { + dag.add_node(Node::opaque( + "postcondition", + vec![ + Port::scalar(self.output_port_name.as_str(), self.output_port_type.as_str()), + Port::scalar("op_ok", "Bool"), + ], + vec![ + Port::scalar(self.output_port_name.as_str(), self.output_port_type.as_str()), + Port::scalar("verified", "Bool"), + ], + postcondition_op, + )); + } + + // Wire edges + if has_precondition { + dag.add_edge(Edge::new( + "precondition", + self.input_port_name.as_str(), + "operation", + self.input_port_name.as_str(), + )); + dag.add_edge(Edge::new("precondition", "pre_ok", "operation", "pre_ok")); + } + + if has_postcondition { + dag.add_edge(Edge::new( + "operation", + self.output_port_name.as_str(), + "postcondition", + self.output_port_name.as_str(), + )); + dag.add_edge(Edge::new("operation", "op_ok", "postcondition", "op_ok")); + } + + // Determine outputs based on what's present + let outputs = if has_postcondition { + vec![ + Port::scalar(self.output_port_name.as_str(), self.output_port_type.as_str()), + Port::scalar("verified", "Bool"), + ] + } else { + vec![ + Port::scalar(self.output_port_name.as_str(), self.output_port_type.as_str()), + Port::scalar("op_ok", "Bool"), + ] + }; + + // Create the outer node with the subdag + Node::subdag( + self.name.as_str(), + vec![Port::scalar(self.input_port_name.as_str(), self.input_port_type.as_str())], + outputs, + dag, + ) + } +} + +/// Phase of an atomic operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AtomicPhase { + /// Verify operation can proceed + Precondition, + /// Execute the main operation + Operation, + /// Verify operation succeeded + Postcondition, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::node::NodeBody; + + #[derive(Debug, Clone)] + enum TestOp { + Precondition, + Operation, + Postcondition, + } + + #[test] + fn test_atomic_builder_operation_only() { + let node = AtomicBuilder::new("simple_op") + .with_operation(TestOp::Operation) + .build(); + + assert_eq!(node.id.0, "simple_op"); + assert!(node.is_subdag()); + + match &node.body { + NodeBody::SubDag(dag) => { + assert_eq!(dag.nodes.len(), 1); + assert_eq!(dag.edges.len(), 0); + } + _ => panic!("Expected SubDag"), + } + } + + #[test] + fn test_atomic_builder_full() { + let node = AtomicBuilder::new("guarded_op") + .with_precondition(TestOp::Precondition) + .with_operation(TestOp::Operation) + .with_postcondition(TestOp::Postcondition) + .build(); + + match &node.body { + NodeBody::SubDag(dag) => { + assert_eq!(dag.nodes.len(), 3); + assert_eq!(dag.edges.len(), 4); + + // Check operation has guard + let op_node = dag.get_node(&"operation".into()).unwrap(); + let pre_ok_port = op_node.inputs.iter().find(|p| p.name.0 == "pre_ok").unwrap(); + assert!(pre_ok_port.guard.is_some()); + } + _ => panic!("Expected SubDag"), + } + } + + #[test] + fn test_atomic_custom_ports() { + let node = AtomicBuilder::new("custom") + .with_operation(TestOp::Operation) + .with_input_port("file_path", "Path") + .with_output_port("result", "Bool") + .build(); + + assert_eq!(node.inputs[0].name.0, "file_path"); + assert_eq!(node.inputs[0].type_id.0, "Path"); + assert_eq!(node.outputs[0].name.0, "result"); + assert_eq!(node.outputs[0].type_id.0, "Bool"); + } +} diff --git a/crates/gunbc-ir/src/patterns/mod.rs b/crates/gunbc-ir/src/patterns/mod.rs new file mode 100644 index 00000000000..b8ca0047212 --- /dev/null +++ b/crates/gunbc-ir/src/patterns/mod.rs @@ -0,0 +1,30 @@ +//! Composable operation patterns for common DAG structures. +//! +//! This module provides builders for common operation patterns: +//! +//! - [`UpsertBuilder`]: Check → Create → Resolve pattern for idempotent resource creation +//! - [`TransactionBuilder`]: Begin → Body → Commit/Rollback pattern for transactional operations +//! - [`AtomicBuilder`]: Precondition → Operation → Postcondition pattern for atomic operations +//! +//! Each builder creates a [`Node`] with a [`NodeBody::SubDag`] containing the pattern's +//! internal structure, with proper guards for conditional execution. +//! +//! # Example +//! +//! ```ignore +//! use gunbc_ir::patterns::UpsertBuilder; +//! +//! let upsert_node = UpsertBuilder::new("install_tool") +//! .with_check(MyOp::CheckInstalled) +//! .with_create(MyOp::Install) +//! .with_resolve(MyOp::Verify) +//! .build(); +//! ``` + +pub mod atomic; +pub mod transaction; +pub mod upsert; + +pub use atomic::AtomicBuilder; +pub use transaction::TransactionBuilder; +pub use upsert::UpsertBuilder; diff --git a/crates/gunbc-ir/src/patterns/transaction.rs b/crates/gunbc-ir/src/patterns/transaction.rs new file mode 100644 index 00000000000..6d027a303d4 --- /dev/null +++ b/crates/gunbc-ir/src/patterns/transaction.rs @@ -0,0 +1,310 @@ +//! Transaction pattern: Begin → Body → Commit/Rollback. +//! +//! The transaction pattern provides transactional semantics for operations: +//! +//! 1. **Begin**: Initialize transaction state (acquire locks, etc.) +//! 2. **Body**: Execute the main operations (a subdag) +//! 3. **Commit**: Finalize on success (guarded by body success) +//! 4. **Rollback**: Cleanup on failure (guarded by body failure) +//! +//! ```text +//! ┌──────────────────────────────────────────────────┐ +//! │ Transaction │ +//! │ ┌───────┐ ┌──────┐ ┌────────┬──────────┐ │ +//! │ │ Begin │───▶│ Body │───▶│ Commit │ Rollback │ │ +//! │ └───────┘ └──────┘ └────────┴──────────┘ │ +//! │ │ ▲ ▲ │ +//! │ │ │ │ │ +//! │ └──── guard ───┴───────┘ │ +//! │ (success/failure) │ +//! └──────────────────────────────────────────────────┘ +//! ``` + +use crate::dag::{Dag, Edge, Guard, Port}; +use crate::node::Node; +use crate::types::Cardinality; +use crate::value::Value; + +/// Builder for the transaction pattern. +/// +/// # Type Parameters +/// +/// - `T`: The operation type used in the DAG +/// +/// # Example +/// +/// ```ignore +/// let txn = TransactionBuilder::new("database_update") +/// .with_begin(DbOp::BeginTransaction) +/// .with_body(update_dag) +/// .with_commit(DbOp::Commit) +/// .with_rollback(DbOp::Rollback) +/// .build(); +/// ``` +pub struct TransactionBuilder { + name: String, + begin_op: Option, + body_dag: Option>, + commit_op: Option, + rollback_op: Option, + // Port configurations + input_port_name: String, + input_port_type: String, + output_port_name: String, + output_port_type: String, +} + +impl TransactionBuilder { + /// Create a new transaction builder with the given name. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + begin_op: None, + body_dag: None, + commit_op: None, + rollback_op: None, + input_port_name: "input".to_string(), + input_port_type: "Any".to_string(), + output_port_name: "output".to_string(), + output_port_type: "Any".to_string(), + } + } + + /// Set the begin operation. + /// + /// The begin operation initializes the transaction context. + /// It should output a `txn_id` on port `"txn_id"`. + pub fn with_begin(mut self, op: T) -> Self { + self.begin_op = Some(op); + self + } + + /// Set the body subgraph. + /// + /// The body contains the main operations to execute within the transaction. + /// It should output `"success": Bool` to indicate if the transaction should commit. + pub fn with_body(mut self, dag: Dag) -> Self { + self.body_dag = Some(dag); + self + } + + /// Set the commit operation. + /// + /// The commit operation finalizes the transaction on success. + /// It is guarded by `success == true`. + pub fn with_commit(mut self, op: T) -> Self { + self.commit_op = Some(op); + self + } + + /// Set the rollback operation. + /// + /// The rollback operation cleans up the transaction on failure. + /// It is guarded by `success == false`. + pub fn with_rollback(mut self, op: T) -> Self { + self.rollback_op = Some(op); + self + } + + /// Configure the input port. + pub fn with_input_port(mut self, name: impl Into, type_id: impl Into) -> Self { + self.input_port_name = name.into(); + self.input_port_type = type_id.into(); + self + } + + /// Configure the output port. + pub fn with_output_port(mut self, name: impl Into, type_id: impl Into) -> Self { + self.output_port_name = name.into(); + self.output_port_type = type_id.into(); + self + } + + /// Build the transaction pattern as a SubDag node. + /// + /// # Panics + /// + /// Panics if any of the required operations (begin, body, commit, rollback) are not set. + pub fn build(self) -> Node { + let begin_op = self.begin_op.expect("begin operation is required"); + let body_dag = self.body_dag.expect("body dag is required"); + let commit_op = self.commit_op.expect("commit operation is required"); + let rollback_op = self.rollback_op.expect("rollback operation is required"); + + let mut dag = Dag::new(); + + // Begin node: initialize transaction + dag.add_node(Node::opaque( + "begin", + vec![Port::scalar(self.input_port_name.as_str(), self.input_port_type.as_str())], + vec![ + Port::scalar("txn_id", "String"), + Port::scalar(self.input_port_name.as_str(), self.input_port_type.as_str()), + ], + begin_op, + )); + + // Body node: the main operations (as a nested subdag) + dag.add_node(Node::subdag( + "body", + vec![ + Port::scalar("txn_id", "String"), + Port::scalar(self.input_port_name.as_str(), self.input_port_type.as_str()), + ], + vec![ + Port::scalar("success", "Bool"), + Port::scalar(self.output_port_name.as_str(), self.output_port_type.as_str()), + ], + body_dag, + )); + + // Commit node: guarded by success == true + dag.add_node(Node::opaque( + "commit", + vec![ + Port::scalar("txn_id", "String"), + Port::guarded_with_cardinality( + "success", + "Bool", + Cardinality::One, + Guard::Eq(Value::Bool(true)), + ), + ], + vec![Port::scalar("committed", "Bool")], + commit_op, + )); + + // Rollback node: guarded by success == false + dag.add_node(Node::opaque( + "rollback", + vec![ + Port::scalar("txn_id", "String"), + Port::guarded_with_cardinality( + "success", + "Bool", + Cardinality::One, + Guard::Eq(Value::Bool(false)), + ), + ], + vec![Port::scalar("rolled_back", "Bool")], + rollback_op, + )); + + // Wire: begin -> body + dag.add_edge(Edge::new("begin", "txn_id", "body", "txn_id")); + dag.add_edge(Edge::new( + "begin", + self.input_port_name.as_str(), + "body", + self.input_port_name.as_str(), + )); + + // Wire: body.success -> commit.success and rollback.success + dag.add_edge(Edge::new("body", "success", "commit", "success")); + dag.add_edge(Edge::new("body", "success", "rollback", "success")); + + // Wire: begin.txn_id -> commit.txn_id and rollback.txn_id + dag.add_edge(Edge::new("begin", "txn_id", "commit", "txn_id")); + dag.add_edge(Edge::new("begin", "txn_id", "rollback", "txn_id")); + + // Create the outer node with the subdag + Node::subdag( + self.name.as_str(), + vec![Port::scalar(self.input_port_name.as_str(), self.input_port_type.as_str())], + vec![ + Port::scalar(self.output_port_name.as_str(), self.output_port_type.as_str()), + Port::scalar("committed", "Bool"), + ], + dag, + ) + } +} + +/// Phase of a transaction operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransactionPhase { + /// Initialize transaction (acquire locks, etc.) + Begin, + /// Execute main operations + Body, + /// Finalize on success + Commit, + /// Cleanup on failure + Rollback, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::node::NodeBody; + + #[derive(Debug, Clone)] + enum TestOp { + Begin, + Commit, + Rollback, + BodyOp, + } + + fn empty_body_dag() -> Dag { + let mut dag = Dag::new(); + dag.add_node(Node::opaque( + "op", + vec![Port::scalar("txn_id", "String"), Port::scalar("input", "Any")], + vec![Port::scalar("success", "Bool"), Port::scalar("output", "Any")], + TestOp::BodyOp, + )); + dag + } + + #[test] + fn test_transaction_builder_creates_subdag() { + let node = TransactionBuilder::new("test_txn") + .with_begin(TestOp::Begin) + .with_body(empty_body_dag()) + .with_commit(TestOp::Commit) + .with_rollback(TestOp::Rollback) + .build(); + + assert_eq!(node.id.0, "test_txn"); + assert!(node.is_subdag()); + + // Check inputs/outputs + assert_eq!(node.inputs.len(), 1); + assert_eq!(node.outputs.len(), 2); + } + + #[test] + fn test_transaction_subdag_structure() { + let node = TransactionBuilder::new("test") + .with_begin(TestOp::Begin) + .with_body(empty_body_dag()) + .with_commit(TestOp::Commit) + .with_rollback(TestOp::Rollback) + .build(); + + match &node.body { + NodeBody::SubDag(dag) => { + assert_eq!(dag.nodes.len(), 4); + + // Check node names + let node_names: Vec<_> = dag.nodes.iter().map(|n| n.id.0.as_str()).collect(); + assert!(node_names.contains(&"begin")); + assert!(node_names.contains(&"body")); + assert!(node_names.contains(&"commit")); + assert!(node_names.contains(&"rollback")); + + // Check commit has guard + let commit_node = dag.get_node(&"commit".into()).unwrap(); + let success_port = commit_node.inputs.iter().find(|p| p.name.0 == "success").unwrap(); + assert!(success_port.guard.is_some()); + + // Check rollback has guard + let rollback_node = dag.get_node(&"rollback".into()).unwrap(); + let success_port = rollback_node.inputs.iter().find(|p| p.name.0 == "success").unwrap(); + assert!(success_port.guard.is_some()); + } + _ => panic!("Expected SubDag"), + } + } +} diff --git a/crates/gunbc-ir/src/patterns/upsert.rs b/crates/gunbc-ir/src/patterns/upsert.rs new file mode 100644 index 00000000000..d92cbfe9662 --- /dev/null +++ b/crates/gunbc-ir/src/patterns/upsert.rs @@ -0,0 +1,257 @@ +//! Upsert pattern: Check → Create → Resolve. +//! +//! The upsert pattern is used for idempotent resource creation: +//! +//! 1. **Check**: Determine if the resource already exists (read-only) +//! 2. **Create**: If not exists, create the resource (guarded by check result) +//! 3. **Resolve**: Verify the resource exists and return its handle (read-only) +//! +//! ```text +//! ┌─────────────────────────────────────────────┐ +//! │ Upsert │ +//! │ ┌───────┐ ┌────────┐ ┌─────────┐ │ +//! │ │ Check │───▶│ Create │───▶│ Resolve │ │ +//! │ └───────┘ └────────┘ └─────────┘ │ +//! │ │ │ │ │ +//! │ └── guard ───┘ │ │ +//! │ (exists=false) ▼ │ +//! │ (boundary) │ +//! └─────────────────────────────────────────────┘ +//! ``` +//! +//! The Create node has a guard that only executes if Check returns `false`. + +use crate::dag::{Dag, Edge, Guard, Port}; +use crate::node::Node; +use crate::types::Cardinality; +use crate::value::Value; + +/// Builder for the upsert pattern. +/// +/// # Type Parameters +/// +/// - `T`: The operation type used in the DAG +/// +/// # Example +/// +/// ```ignore +/// let upsert = UpsertBuilder::new("install_tool") +/// .with_check(DepsOp::CheckInstalled) +/// .with_create(DepsOp::Install) +/// .with_resolve(DepsOp::Verify) +/// .build(); +/// ``` +pub struct UpsertBuilder { + name: String, + check_op: Option, + create_op: Option, + resolve_op: Option, + // Port configurations + input_port_name: String, + input_port_type: String, + output_port_name: String, + output_port_type: String, +} + +impl UpsertBuilder { + /// Create a new upsert builder with the given name. + /// + /// The name is used as the node ID for the upsert subgraph. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + check_op: None, + create_op: None, + resolve_op: None, + input_port_name: "resource_id".to_string(), + input_port_type: "String".to_string(), + output_port_name: "handle".to_string(), + output_port_type: "String".to_string(), + } + } + + /// Set the check operation. + /// + /// The check operation should output a `Bool` on port `"exists"`. + pub fn with_check(mut self, op: T) -> Self { + self.check_op = Some(op); + self + } + + /// Set the create operation. + /// + /// The create operation is guarded by `exists == false`. + pub fn with_create(mut self, op: T) -> Self { + self.create_op = Some(op); + self + } + + /// Set the resolve operation. + /// + /// The resolve operation always runs and outputs the final handle. + pub fn with_resolve(mut self, op: T) -> Self { + self.resolve_op = Some(op); + self + } + + /// Configure the input port. + pub fn with_input_port(mut self, name: impl Into, type_id: impl Into) -> Self { + self.input_port_name = name.into(); + self.input_port_type = type_id.into(); + self + } + + /// Configure the output port. + pub fn with_output_port(mut self, name: impl Into, type_id: impl Into) -> Self { + self.output_port_name = name.into(); + self.output_port_type = type_id.into(); + self + } + + /// Build the upsert pattern as a SubDag node. + /// + /// # Panics + /// + /// Panics if any of the operations (check, create, resolve) are not set. + pub fn build(self) -> Node { + let check_op = self.check_op.expect("check operation is required"); + let create_op = self.create_op.expect("create operation is required"); + let resolve_op = self.resolve_op.expect("resolve operation is required"); + + let mut dag = Dag::new(); + + // Check node: determines if resource exists + dag.add_node(Node::opaque( + "check", + vec![Port::scalar(self.input_port_name.as_str(), self.input_port_type.as_str())], + vec![Port::scalar("exists", "Bool")], + check_op, + )); + + // Create node: guarded by exists == false + dag.add_node(Node::opaque( + "create", + vec![ + Port::scalar(self.input_port_name.as_str(), self.input_port_type.as_str()), + Port::guarded_with_cardinality( + "exists", + "Bool", + Cardinality::One, + Guard::Eq(Value::Bool(false)), + ), + ], + vec![Port::scalar("created", "Bool")], + create_op, + )); + + // Resolve node: always runs, verifies final state + dag.add_node(Node::opaque( + "resolve", + vec![Port::scalar(self.input_port_name.as_str(), self.input_port_type.as_str())], + vec![Port::scalar(self.output_port_name.as_str(), self.output_port_type.as_str())], + resolve_op, + )); + + // Wire: check.exists -> create.exists (for guard) + dag.add_edge(Edge::new("check", "exists", "create", "exists")); + + // Create the outer node with the subdag + Node::subdag( + self.name.as_str(), + vec![Port::scalar(self.input_port_name.as_str(), self.input_port_type.as_str())], + vec![ + Port::scalar(self.output_port_name.as_str(), self.output_port_type.as_str()), + Port::scalar("was_created", "Bool"), + ], + dag, + ) + } +} + +/// Phase of an upsert operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpsertPhase { + /// Check if resource exists (read-only) + Check, + /// Create resource if missing (idempotent, guarded) + Create, + /// Verify and return resolved handle (read-only) + Resolve, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::node::NodeBody; + + #[derive(Debug, Clone)] + enum TestOp { + Check, + Create, + Resolve, + } + + #[test] + fn test_upsert_builder_creates_subdag() { + let node = UpsertBuilder::new("test_upsert") + .with_check(TestOp::Check) + .with_create(TestOp::Create) + .with_resolve(TestOp::Resolve) + .build(); + + assert_eq!(node.id.0, "test_upsert"); + assert!(node.is_subdag()); + + // Check inputs/outputs + assert_eq!(node.inputs.len(), 1); + assert_eq!(node.inputs[0].name.0, "resource_id"); + + assert_eq!(node.outputs.len(), 2); + assert_eq!(node.outputs[0].name.0, "handle"); + assert_eq!(node.outputs[1].name.0, "was_created"); + } + + #[test] + fn test_upsert_subdag_structure() { + let node = UpsertBuilder::new("test") + .with_check(TestOp::Check) + .with_create(TestOp::Create) + .with_resolve(TestOp::Resolve) + .build(); + + match &node.body { + NodeBody::SubDag(dag) => { + assert_eq!(dag.nodes.len(), 3); + assert_eq!(dag.edges.len(), 1); + + // Check node names + let node_names: Vec<_> = dag.nodes.iter().map(|n| n.id.0.as_str()).collect(); + assert!(node_names.contains(&"check")); + assert!(node_names.contains(&"create")); + assert!(node_names.contains(&"resolve")); + + // Check create has guard + let create_node = dag.get_node(&"create".into()).unwrap(); + let exists_port = create_node.inputs.iter().find(|p| p.name.0 == "exists").unwrap(); + assert!(exists_port.guard.is_some()); + } + _ => panic!("Expected SubDag"), + } + } + + #[test] + fn test_upsert_custom_ports() { + let node = UpsertBuilder::new("custom") + .with_check(TestOp::Check) + .with_create(TestOp::Create) + .with_resolve(TestOp::Resolve) + .with_input_port("tool_name", "ToolId") + .with_output_port("install_path", "Path") + .build(); + + assert_eq!(node.inputs[0].name.0, "tool_name"); + assert_eq!(node.inputs[0].type_id.0, "ToolId"); + assert_eq!(node.outputs[0].name.0, "install_path"); + assert_eq!(node.outputs[0].type_id.0, "Path"); + } +} diff --git a/crates/gunbc-ir/src/transport/file.rs b/crates/gunbc-ir/src/transport/file.rs new file mode 100644 index 00000000000..cc1cf0066e2 --- /dev/null +++ b/crates/gunbc-ir/src/transport/file.rs @@ -0,0 +1,192 @@ +//! File operation request/response types. + +use serde::{Deserialize, Serialize}; + +/// File operation type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FileOp { + /// Read file contents + Read, + /// Write file (create or overwrite) + Write, + /// Append to file + Append, + /// Delete file + Delete, + /// Check if file exists + Exists, + /// Create directory + CreateDir, +} + +/// File operation request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileRequest { + /// File path + pub path: String, + /// Operation to perform + pub operation: FileOp, + /// Content for write/append operations + pub content: Option, + /// Create parent directories if needed (for write operations) + pub create_parents: bool, +} + +/// File operation response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileResponse { + /// File path + pub path: String, + /// Operation that was performed + pub operation: FileOp, + /// Whether the operation succeeded + pub success: bool, + /// Content (for read operations) + pub content: Option, + /// Whether the file exists (for exists operations) + pub exists: Option, + /// Error message if operation failed + pub error: Option, +} + +impl FileRequest { + /// Create a read request. + pub fn read(path: impl Into) -> Self { + Self { + path: path.into(), + operation: FileOp::Read, + content: None, + create_parents: false, + } + } + + /// Create a write request. + pub fn write(path: impl Into, content: impl Into) -> Self { + Self { + path: path.into(), + operation: FileOp::Write, + content: Some(content.into()), + create_parents: true, + } + } + + /// Create an append request. + pub fn append(path: impl Into, content: impl Into) -> Self { + Self { + path: path.into(), + operation: FileOp::Append, + content: Some(content.into()), + create_parents: true, + } + } + + /// Create a delete request. + pub fn delete(path: impl Into) -> Self { + Self { + path: path.into(), + operation: FileOp::Delete, + content: None, + create_parents: false, + } + } + + /// Create an exists check request. + pub fn exists(path: impl Into) -> Self { + Self { + path: path.into(), + operation: FileOp::Exists, + content: None, + create_parents: false, + } + } + + /// Create a directory creation request. + pub fn create_dir(path: impl Into) -> Self { + Self { + path: path.into(), + operation: FileOp::CreateDir, + content: None, + create_parents: true, + } + } + + /// Set whether to create parent directories. + pub fn with_create_parents(mut self, create: bool) -> Self { + self.create_parents = create; + self + } +} + +impl FileResponse { + /// Create a successful response for a write operation. + pub fn written(path: impl Into) -> Self { + Self { + path: path.into(), + operation: FileOp::Write, + success: true, + content: None, + exists: None, + error: None, + } + } + + /// Create a successful response for a read operation. + pub fn read_ok(path: impl Into, content: impl Into) -> Self { + Self { + path: path.into(), + operation: FileOp::Read, + success: true, + content: Some(content.into()), + exists: None, + error: None, + } + } + + /// Create an error response. + pub fn error(path: impl Into, operation: FileOp, error: impl Into) -> Self { + Self { + path: path.into(), + operation, + success: false, + content: None, + exists: None, + error: Some(error.into()), + } + } + + /// Create an exists check response. + pub fn exists_result(path: impl Into, exists: bool) -> Self { + Self { + path: path.into(), + operation: FileOp::Exists, + success: true, + content: None, + exists: Some(exists), + error: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_file_request_builders() { + let read = FileRequest::read("/tmp/test.txt"); + assert_eq!(read.operation, FileOp::Read); + assert_eq!(read.path, "/tmp/test.txt"); + + let write = FileRequest::write("/tmp/test.txt", "content"); + assert_eq!(write.operation, FileOp::Write); + assert_eq!(write.content, Some("content".to_string())); + assert!(write.create_parents); + } + + #[test] + fn test_file_response() { + let resp = FileResponse::read_ok("/tmp/test.txt", "file content"); + assert!(resp.success); + assert_eq!(resp.content, Some("file content".to_string())); + } +} diff --git a/crates/gunbc-ir/src/transport/gist.rs b/crates/gunbc-ir/src/transport/gist.rs new file mode 100644 index 00000000000..994192b6143 --- /dev/null +++ b/crates/gunbc-ir/src/transport/gist.rs @@ -0,0 +1,214 @@ +//! GitHub Gist-specific request types. +//! +//! This module provides convenience builders for GitHub Gist API requests, +//! which can be converted to either REST or Shell transport requests. + +use super::rest::RestRequest; +use super::{ShellRequest, TransportRequest}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// GitHub Gist request. +/// +/// This is a high-level representation of a gist operation that can be +/// converted to either a REST API request or a shell command (gh CLI). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GistRequest { + /// Files to include in the gist + pub files: HashMap, + /// Whether the gist should be public + pub public: bool, + /// Gist description (optional) + pub description: Option, +} + +/// A file in a gist. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GistFile { + /// File content + pub content: String, +} + +impl GistRequest { + /// Create a new gist request. + pub fn new() -> Self { + Self { + files: HashMap::new(), + public: false, + description: None, + } + } + + /// Add a file to the gist. + pub fn file(mut self, name: impl Into, content: impl Into) -> Self { + self.files.insert( + name.into(), + GistFile { + content: content.into(), + }, + ); + self + } + + /// Set whether the gist is public. + pub fn public(mut self, public: bool) -> Self { + self.public = public; + self + } + + /// Set the description. + pub fn description(mut self, desc: impl Into) -> Self { + self.description = Some(desc.into()); + self + } + + /// Convert to a REST API request. + /// + /// Uses the GitHub Gist API: POST https://api.github.com/gists + pub fn to_rest_request(&self) -> TransportRequest { + let files_json: serde_json::Map = self + .files + .iter() + .map(|(name, file)| { + ( + name.clone(), + serde_json::json!({ "content": file.content }), + ) + }) + .collect(); + + let mut body = serde_json::json!({ + "public": self.public, + "files": files_json, + }); + + if let Some(ref desc) = self.description { + body["description"] = serde_json::Value::String(desc.clone()); + } + + let request = RestRequest::post("https://api.github.com/gists") + .json(body) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .auth_env("GITHUB_TOKEN"); + + TransportRequest::Rest(request) + } + + /// Convert to a shell request using the gh CLI. + /// + /// This is useful when a GitHub CLI is available and authenticated. + pub fn to_shell_request(&self) -> TransportRequest { + // Get the first file (gh CLI creates gist from a single file or stdin) + let (filename, content) = self + .files + .iter() + .next() + .map(|(n, f)| (n.clone(), f.content.clone())) + .unwrap_or_else(|| ("gist.txt".to_string(), String::new())); + + let mut req = ShellRequest::new("gh") + .args(["gist", "create", "-f", &filename]) + .arg("-") // Read from stdin + .stdin(content); + + if self.public { + req = req.arg("--public"); + } + + if let Some(ref desc) = self.description { + req = req.args(["--desc", desc]); + } + + TransportRequest::Shell(req) + } +} + +impl Default for GistRequest { + fn default() -> Self { + Self::new() + } +} + +/// Parse a gist URL from a shell response. +pub fn parse_gist_url_from_shell(stdout: &str) -> Option { + stdout + .lines() + .find(|line| line.starts_with("https://gist.github.com/")) + .map(|s| s.trim().to_string()) +} + +/// Parse a gist URL from a REST response. +pub fn parse_gist_url_from_rest(body: &serde_json::Value) -> Option { + body.get("html_url").and_then(|v| v.as_str()).map(String::from) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_gist_request_builder() { + let req = GistRequest::new() + .file("test.md", "# Test content") + .file("code.rs", "fn main() {}") + .public(true) + .description("A test gist"); + + assert_eq!(req.files.len(), 2); + assert!(req.public); + assert_eq!(req.description, Some("A test gist".to_string())); + } + + #[test] + fn test_to_rest_request() { + let gist = GistRequest::new() + .file("test.md", "# Test") + .public(true); + + let transport = gist.to_rest_request(); + + match transport { + TransportRequest::Rest(req) => { + assert_eq!(req.url, "https://api.github.com/gists"); + assert!(req.body.is_some()); + } + _ => panic!("expected REST request"), + } + } + + #[test] + fn test_to_shell_request() { + let gist = GistRequest::new() + .file("test.md", "# Test") + .public(true); + + let transport = gist.to_shell_request(); + + match transport { + TransportRequest::Shell(req) => { + assert_eq!(req.command, "gh"); + assert!(req.args.contains(&"gist".to_string())); + assert!(req.args.contains(&"--public".to_string())); + } + _ => panic!("expected Shell request"), + } + } + + #[test] + fn test_parse_gist_url() { + let stdout = "https://gist.github.com/abc123\n"; + assert_eq!( + parse_gist_url_from_shell(stdout), + Some("https://gist.github.com/abc123".to_string()) + ); + + let body = serde_json::json!({ + "html_url": "https://gist.github.com/xyz789" + }); + assert_eq!( + parse_gist_url_from_rest(&body), + Some("https://gist.github.com/xyz789".to_string()) + ); + } +} diff --git a/crates/gunbc-ir/src/transport/http.rs b/crates/gunbc-ir/src/transport/http.rs new file mode 100644 index 00000000000..400d3aa6202 --- /dev/null +++ b/crates/gunbc-ir/src/transport/http.rs @@ -0,0 +1,164 @@ +//! Low-level HTTP request/response types. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// HTTP method. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum HttpMethod { + Get, + Post, + Put, + Patch, + Delete, + Head, + Options, +} + +impl HttpMethod { + /// Parse from string. + pub fn parse(s: &str) -> Option { + match s.to_uppercase().as_str() { + "GET" => Some(HttpMethod::Get), + "POST" => Some(HttpMethod::Post), + "PUT" => Some(HttpMethod::Put), + "PATCH" => Some(HttpMethod::Patch), + "DELETE" => Some(HttpMethod::Delete), + "HEAD" => Some(HttpMethod::Head), + "OPTIONS" => Some(HttpMethod::Options), + _ => None, + } + } + + /// Get the method name as a string. + pub fn as_str(&self) -> &'static str { + match self { + HttpMethod::Get => "GET", + HttpMethod::Post => "POST", + HttpMethod::Put => "PUT", + HttpMethod::Patch => "PATCH", + HttpMethod::Delete => "DELETE", + HttpMethod::Head => "HEAD", + HttpMethod::Options => "OPTIONS", + } + } +} + +impl std::fmt::Display for HttpMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// Raw HTTP request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HttpRequest { + /// Request URL + pub url: String, + /// HTTP method + pub method: HttpMethod, + /// Request headers + #[serde(default)] + pub headers: HashMap, + /// Request body (raw bytes as base64 or string) + pub body: Option, + /// Timeout in milliseconds + pub timeout_ms: Option, +} + +/// Raw HTTP response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HttpResponse { + /// HTTP status code + pub status: u16, + /// Response headers + #[serde(default)] + pub headers: HashMap, + /// Response body + pub body: String, +} + +impl HttpRequest { + /// Create a new GET request. + pub fn get(url: impl Into) -> Self { + Self { + url: url.into(), + method: HttpMethod::Get, + headers: HashMap::new(), + body: None, + timeout_ms: None, + } + } + + /// Create a new POST request. + pub fn post(url: impl Into) -> Self { + Self { + url: url.into(), + method: HttpMethod::Post, + headers: HashMap::new(), + body: None, + timeout_ms: None, + } + } + + /// Add a header. + pub fn header(mut self, key: impl Into, value: impl Into) -> Self { + self.headers.insert(key.into(), value.into()); + self + } + + /// Set the body. + pub fn body(mut self, body: impl Into) -> Self { + self.body = Some(body.into()); + self + } + + /// Set the timeout. + pub fn timeout(mut self, ms: u64) -> Self { + self.timeout_ms = Some(ms); + self + } +} + +impl HttpResponse { + /// Check if the response was successful (2xx status). + pub fn is_success(&self) -> bool { + (200..300).contains(&self.status) + } + + /// Check if the response was a client error (4xx status). + pub fn is_client_error(&self) -> bool { + (400..500).contains(&self.status) + } + + /// Check if the response was a server error (5xx status). + pub fn is_server_error(&self) -> bool { + (500..600).contains(&self.status) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_http_method_parse() { + assert_eq!(HttpMethod::parse("GET"), Some(HttpMethod::Get)); + assert_eq!(HttpMethod::parse("post"), Some(HttpMethod::Post)); + assert_eq!(HttpMethod::parse("INVALID"), None); + } + + #[test] + fn test_http_request_builder() { + let req = HttpRequest::post("https://api.example.com") + .header("Content-Type", "application/json") + .body(r#"{"test": true}"#) + .timeout(5000); + + assert_eq!(req.method, HttpMethod::Post); + assert_eq!(req.url, "https://api.example.com"); + assert_eq!(req.headers.get("Content-Type"), Some(&"application/json".to_string())); + assert_eq!(req.body, Some(r#"{"test": true}"#.to_string())); + assert_eq!(req.timeout_ms, Some(5000)); + } +} diff --git a/crates/gunbc-ir/src/transport/mod.rs b/crates/gunbc-ir/src/transport/mod.rs new file mode 100644 index 00000000000..43bcc64e635 --- /dev/null +++ b/crates/gunbc-ir/src/transport/mod.rs @@ -0,0 +1,159 @@ +//! Transport layer types for I/O abstraction. +//! +//! This module provides request/response types for different transport mechanisms: +//! - REST/HTTP for web APIs +//! - File operations for filesystem I/O +//! - TCP for raw network connections +//! - Shell for command execution +//! +//! The key insight is that all world I/O can be modeled as request/response pairs, +//! allowing business logic to remain pure while transport execution happens at +//! well-defined boundaries. + +pub mod file; +pub mod gist; +pub mod http; +pub mod rest; +pub mod tcp; + +pub use file::{FileOp, FileRequest, FileResponse}; +pub use gist::GistRequest; +pub use http::{HttpMethod, HttpRequest, HttpResponse}; +pub use rest::{AuthMethod, RestRequest, RestResponse}; +pub use tcp::{TcpRequest, TcpResponse}; + +use serde::{Deserialize, Serialize}; + +/// Unified transport request enum. +/// +/// All I/O operations are represented as one of these request types, +/// allowing uniform handling at transport boundaries. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TransportRequest { + /// REST API request + Rest(RestRequest), + /// Raw HTTP request + Http(HttpRequest), + /// File operation request + File(FileRequest), + /// TCP connection request + Tcp(TcpRequest), + /// Shell command request + Shell(ShellRequest), +} + +/// Unified transport response enum. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TransportResponse { + /// REST API response + Rest(RestResponse), + /// Raw HTTP response + Http(HttpResponse), + /// File operation response + File(FileResponse), + /// TCP connection response + Tcp(TcpResponse), + /// Shell command response + Shell(ShellResponse), +} + +/// Shell command request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShellRequest { + /// Command to execute + pub command: String, + /// Command arguments + pub args: Vec, + /// Working directory (optional) + pub cwd: Option, + /// Environment variables + #[serde(default)] + pub env: std::collections::HashMap, + /// Standard input to pipe to the command + pub stdin: Option, +} + +/// Shell command response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShellResponse { + /// Exit code + pub exit_code: i32, + /// Standard output + pub stdout: String, + /// Standard error + pub stderr: String, +} + +impl ShellRequest { + /// Create a new shell request. + pub fn new(command: impl Into) -> Self { + Self { + command: command.into(), + args: Vec::new(), + cwd: None, + env: std::collections::HashMap::new(), + stdin: None, + } + } + + /// Add an argument. + pub fn arg(mut self, arg: impl Into) -> Self { + self.args.push(arg.into()); + self + } + + /// Add multiple arguments. + pub fn args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.args.extend(args.into_iter().map(|s| s.into())); + self + } + + /// Set the working directory. + pub fn cwd(mut self, cwd: impl Into) -> Self { + self.cwd = Some(cwd.into()); + self + } + + /// Set standard input. + pub fn stdin(mut self, stdin: impl Into) -> Self { + self.stdin = Some(stdin.into()); + self + } + + /// Set an environment variable. + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.env.insert(key.into(), value.into()); + self + } +} + +impl ShellResponse { + /// Check if the command succeeded (exit code 0). + pub fn success(&self) -> bool { + self.exit_code == 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_shell_request_builder() { + let req = ShellRequest::new("gh") + .args(["gist", "create"]) + .arg("-f") + .arg("test.md") + .cwd("/tmp") + .stdin("# Test"); + + assert_eq!(req.command, "gh"); + assert_eq!(req.args, vec!["gist", "create", "-f", "test.md"]); + assert_eq!(req.cwd, Some("/tmp".to_string())); + assert_eq!(req.stdin, Some("# Test".to_string())); + } +} diff --git a/crates/gunbc-ir/src/transport/rest.rs b/crates/gunbc-ir/src/transport/rest.rs new file mode 100644 index 00000000000..2b8c0ad838b --- /dev/null +++ b/crates/gunbc-ir/src/transport/rest.rs @@ -0,0 +1,213 @@ +//! REST API request/response types. + +use super::http::HttpMethod; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Authentication method for REST APIs. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum AuthMethod { + /// No authentication + None, + /// Bearer token (e.g., OAuth, JWT) + Bearer(String), + /// Basic authentication (username:password base64 encoded) + Basic { username: String, password: String }, + /// API key in header + ApiKey { header: String, key: String }, + /// Environment variable reference (resolved at execution time) + EnvVar(String), +} + +/// REST API request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RestRequest { + /// Request URL + pub url: String, + /// HTTP method + pub method: HttpMethod, + /// Request headers + #[serde(default)] + pub headers: HashMap, + /// JSON request body + pub body: Option, + /// Authentication method + pub auth: Option, + /// Query parameters + #[serde(default)] + pub query: HashMap, + /// Timeout in milliseconds + pub timeout_ms: Option, +} + +/// REST API response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RestResponse { + /// HTTP status code + pub status: u16, + /// Response headers + #[serde(default)] + pub headers: HashMap, + /// JSON response body + pub body: serde_json::Value, +} + +impl RestRequest { + /// Create a new GET request. + pub fn get(url: impl Into) -> Self { + Self { + url: url.into(), + method: HttpMethod::Get, + headers: HashMap::new(), + body: None, + auth: None, + query: HashMap::new(), + timeout_ms: None, + } + } + + /// Create a new POST request. + pub fn post(url: impl Into) -> Self { + Self { + url: url.into(), + method: HttpMethod::Post, + headers: HashMap::new(), + body: None, + auth: None, + query: HashMap::new(), + timeout_ms: None, + } + } + + /// Create a new PUT request. + pub fn put(url: impl Into) -> Self { + Self { + url: url.into(), + method: HttpMethod::Put, + headers: HashMap::new(), + body: None, + auth: None, + query: HashMap::new(), + timeout_ms: None, + } + } + + /// Create a new DELETE request. + pub fn delete(url: impl Into) -> Self { + Self { + url: url.into(), + method: HttpMethod::Delete, + headers: HashMap::new(), + body: None, + auth: None, + query: HashMap::new(), + timeout_ms: None, + } + } + + /// Set the JSON body. + pub fn json(mut self, body: serde_json::Value) -> Self { + self.body = Some(body); + self + } + + /// Add a header. + pub fn header(mut self, key: impl Into, value: impl Into) -> Self { + self.headers.insert(key.into(), value.into()); + self + } + + /// Add a query parameter. + pub fn query(mut self, key: impl Into, value: impl Into) -> Self { + self.query.insert(key.into(), value.into()); + self + } + + /// Set bearer token authentication. + pub fn bearer(mut self, token: impl Into) -> Self { + self.auth = Some(AuthMethod::Bearer(token.into())); + self + } + + /// Set authentication from environment variable. + pub fn auth_env(mut self, var_name: impl Into) -> Self { + self.auth = Some(AuthMethod::EnvVar(var_name.into())); + self + } + + /// Set the timeout. + pub fn timeout(mut self, ms: u64) -> Self { + self.timeout_ms = Some(ms); + self + } +} + +impl RestResponse { + /// Create a new response. + pub fn new(status: u16, body: serde_json::Value) -> Self { + Self { + status, + headers: HashMap::new(), + body, + } + } + + /// Create a successful response. + pub fn ok(body: serde_json::Value) -> Self { + Self::new(200, body) + } + + /// Create an error response. + pub fn error(status: u16, message: &str) -> Self { + Self::new(status, serde_json::json!({ "error": message })) + } + + /// Check if the response was successful (2xx status). + pub fn is_success(&self) -> bool { + (200..300).contains(&self.status) + } + + /// Get a field from the JSON body. + pub fn get(&self, key: &str) -> Option<&serde_json::Value> { + self.body.get(key) + } + + /// Get a string field from the JSON body. + pub fn get_str(&self, key: &str) -> Option<&str> { + self.body.get(key).and_then(|v| v.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rest_request_builder() { + let req = RestRequest::post("https://api.github.com/gists") + .json(serde_json::json!({ + "public": true, + "files": { + "test.md": { "content": "# Test" } + } + })) + .bearer("token123") + .header("Accept", "application/vnd.github+json") + .timeout(30000); + + assert_eq!(req.method, HttpMethod::Post); + assert_eq!(req.url, "https://api.github.com/gists"); + assert!(req.body.is_some()); + assert!(matches!(req.auth, Some(AuthMethod::Bearer(_)))); + } + + #[test] + fn test_rest_response() { + let resp = RestResponse::ok(serde_json::json!({ + "html_url": "https://gist.github.com/abc123" + })); + + assert!(resp.is_success()); + assert_eq!(resp.get_str("html_url"), Some("https://gist.github.com/abc123")); + } +} diff --git a/crates/gunbc-ir/src/transport/tcp.rs b/crates/gunbc-ir/src/transport/tcp.rs new file mode 100644 index 00000000000..910167246bf --- /dev/null +++ b/crates/gunbc-ir/src/transport/tcp.rs @@ -0,0 +1,111 @@ +//! TCP connection request/response types. + +use serde::{Deserialize, Serialize}; + +/// TCP connection request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TcpRequest { + /// Host address + pub host: String, + /// Port number + pub port: u16, + /// Data to send + pub data: Option, + /// Connection timeout in milliseconds + pub connect_timeout_ms: Option, + /// Read timeout in milliseconds + pub read_timeout_ms: Option, +} + +/// TCP connection response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TcpResponse { + /// Whether the connection was successful + pub connected: bool, + /// Data received + pub data: Option, + /// Bytes sent + pub bytes_sent: usize, + /// Bytes received + pub bytes_received: usize, + /// Error message if connection failed + pub error: Option, +} + +impl TcpRequest { + /// Create a new TCP request. + pub fn new(host: impl Into, port: u16) -> Self { + Self { + host: host.into(), + port, + data: None, + connect_timeout_ms: Some(30000), + read_timeout_ms: Some(30000), + } + } + + /// Set the data to send. + pub fn data(mut self, data: impl Into) -> Self { + self.data = Some(data.into()); + self + } + + /// Set the connection timeout. + pub fn connect_timeout(mut self, ms: u64) -> Self { + self.connect_timeout_ms = Some(ms); + self + } + + /// Set the read timeout. + pub fn read_timeout(mut self, ms: u64) -> Self { + self.read_timeout_ms = Some(ms); + self + } +} + +impl TcpResponse { + /// Create a successful response. + pub fn ok(data: Option, bytes_sent: usize, bytes_received: usize) -> Self { + Self { + connected: true, + data, + bytes_sent, + bytes_received, + error: None, + } + } + + /// Create an error response. + pub fn error(error: impl Into) -> Self { + Self { + connected: false, + data: None, + bytes_sent: 0, + bytes_received: 0, + error: Some(error.into()), + } + } + + /// Check if the connection was successful. + pub fn is_ok(&self) -> bool { + self.connected && self.error.is_none() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tcp_request_builder() { + let req = TcpRequest::new("localhost", 8080) + .data("PING\n") + .connect_timeout(5000) + .read_timeout(10000); + + assert_eq!(req.host, "localhost"); + assert_eq!(req.port, 8080); + assert_eq!(req.data, Some("PING\n".to_string())); + assert_eq!(req.connect_timeout_ms, Some(5000)); + } +} diff --git a/crates/gunbc-ir/src/types.rs b/crates/gunbc-ir/src/types.rs index 35e4696ce5f..dfc14d86dec 100644 --- a/crates/gunbc-ir/src/types.rs +++ b/crates/gunbc-ir/src/types.rs @@ -1,73 +1,174 @@ +//! Core identifier types. + +use serde::{Deserialize, Serialize}; use std::fmt; +/// Set-theoretic cardinality for port values. +/// +/// Every port has a cardinality that describes how many values can flow through it. +/// This enables semantic test generation and runtime validation. +/// +/// # Mathematical Mapping +/// +/// - `Zero` = ∅ (empty set) +/// - `One` = {x} (singleton, exactly one element) +/// - `ZeroOrOne` = {x}? (optional, zero or one element) +/// - `ZeroOrMore` = {x}* (Kleene star, any number of elements) +/// - `OneOrMore` = {x}+ (Kleene plus, at least one element) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +pub enum Cardinality { + /// Exactly zero elements (empty set, void). + /// Used for signals that carry no data, just timing. + Zero, + + /// Exactly one element (scalar, required). + /// This is the default for most ports. + #[default] + One, + + /// Zero or one element (optional/nullable). + /// The value may or may not be present. + ZeroOrOne, + + /// Zero or more elements (list, may be empty). + /// Represents a potentially empty collection. + ZeroOrMore, + + /// One or more elements (non-empty list). + /// Represents a collection with at least one element. + OneOrMore, +} + +impl Cardinality { + /// Returns true if this cardinality allows zero elements. + pub fn allows_empty(&self) -> bool { + matches!(self, Cardinality::Zero | Cardinality::ZeroOrOne | Cardinality::ZeroOrMore) + } + + /// Returns true if this cardinality allows exactly one element. + pub fn allows_one(&self) -> bool { + !matches!(self, Cardinality::Zero) + } + + /// Returns true if this cardinality allows multiple elements. + pub fn allows_many(&self) -> bool { + matches!(self, Cardinality::ZeroOrMore | Cardinality::OneOrMore) + } + + /// Returns true if this cardinality requires at least one element. + pub fn requires_one(&self) -> bool { + matches!(self, Cardinality::One | Cardinality::OneOrMore) + } + + /// Returns the test cases that should be generated for this cardinality. + pub fn test_cases(&self) -> Vec { + match self { + Cardinality::Zero => vec![CardinalityCase::Empty], + Cardinality::One => vec![CardinalityCase::One], + Cardinality::ZeroOrOne => vec![CardinalityCase::Empty, CardinalityCase::One], + Cardinality::ZeroOrMore => vec![CardinalityCase::Empty, CardinalityCase::One, CardinalityCase::Many], + Cardinality::OneOrMore => vec![CardinalityCase::One, CardinalityCase::Many], + } + } +} + +impl fmt::Display for Cardinality { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Cardinality::Zero => write!(f, "0"), + Cardinality::One => write!(f, "1"), + Cardinality::ZeroOrOne => write!(f, "0..1"), + Cardinality::ZeroOrMore => write!(f, "0..*"), + Cardinality::OneOrMore => write!(f, "1..*"), + } + } +} + +/// A specific cardinality case for test generation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CardinalityCase { + /// Test with zero elements (empty list, None, etc.) + Empty, + /// Test with exactly one element + One, + /// Test with multiple elements (typically 2-3) + Many, +} + /// Unique identifier for a node within a DAG. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct NodeId(pub String); -/// Unique identifier for a port on a node. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct PortName(pub String); +impl NodeId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } +} -/// Type identifier for port type checking. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct TypeId(pub String); +impl From<&str> for NodeId { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} -/// Identifies which tool a node belongs to. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct ToolId(pub String); +impl From for NodeId { + fn from(s: String) -> Self { + Self(s) + } +} -/// A secret value that redacts its contents in Debug output and has no Display. -#[derive(Clone, PartialEq, Eq)] -pub struct Secret(pub T); +impl std::fmt::Display for NodeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} -impl fmt::Debug for Secret { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("") +/// Name of a port on a node. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct PortName(pub String); + +impl PortName { + pub fn new(name: impl Into) -> Self { + Self(name.into()) } } -impl Secret { - pub fn into_inner(self) -> T { - self.0 +impl From<&str> for PortName { + fn from(s: &str) -> Self { + Self(s.to_string()) } +} - pub fn as_inner(&self) -> &T { - &self.0 +impl From for PortName { + fn from(s: String) -> Self { + Self(s) } } -/// Behavior classification for a node. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum BehaviorKind { - Pure, - Observe, - WritesWorld(Idempotency), +impl std::fmt::Display for PortName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } } -/// Idempotency declaration for WritesWorld nodes. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Idempotency { - Idempotent, - NotIdempotent, +/// Type identifier for type checking edges. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TypeId(pub String); + +impl TypeId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } } -/// Whether a pattern-based node was instantiated. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PatternDecision { - Instantiated, - NotApplicable { reason: String }, +impl From<&str> for TypeId { + fn from(s: &str) -> Self { + Self(s.to_string()) + } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn secret_redaction() { - let secret = Secret("my_password".to_string()); - let debug_output = format!("{:?}", secret); - assert_eq!(debug_output, ""); - assert!(!debug_output.contains("my_password")); - assert_eq!(secret.as_inner(), "my_password"); +impl std::fmt::Display for TypeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) } } diff --git a/crates/gunbc-ir/src/value.rs b/crates/gunbc-ir/src/value.rs new file mode 100644 index 00000000000..5c9a33c8bf7 --- /dev/null +++ b/crates/gunbc-ir/src/value.rs @@ -0,0 +1,99 @@ +//! Runtime values flowing through the DAG. + +use crate::transport::{TransportRequest, TransportResponse}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fmt; + +/// Runtime value flowing between nodes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Value { + /// Unit value (no data) + Unit, + /// Boolean + Bool(bool), + /// String + Str(String), + /// Integer + Int(i64), + /// List of strings + StrList(Vec), + /// Map from string to string + MapStrStr(BTreeMap), + /// JSON value (for complex data) + Json(serde_json::Value), + /// Transport request (for I/O operations) + Request(TransportRequest), + /// Transport response (from I/O operations) + Response(TransportResponse), + /// Node was skipped (guard evaluated to false) + Skipped, +} + +impl Value { + /// Check if this value represents a skipped node. + pub fn is_skipped(&self) -> bool { + matches!(self, Value::Skipped) + } + + /// Check if this is a transport request. + pub fn is_request(&self) -> bool { + matches!(self, Value::Request(_)) + } + + /// Check if this is a transport response. + pub fn is_response(&self) -> bool { + matches!(self, Value::Response(_)) + } + + /// Try to extract a transport request. + pub fn as_request(&self) -> Option<&TransportRequest> { + match self { + Value::Request(r) => Some(r), + _ => None, + } + } + + /// Try to extract a transport response. + pub fn as_response(&self) -> Option<&TransportResponse> { + match self { + Value::Response(r) => Some(r), + _ => None, + } + } +} + +impl fmt::Display for Value { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Value::Unit => write!(f, "()"), + Value::Bool(b) => write!(f, "{b}"), + Value::Str(s) => write!(f, "{s}"), + Value::Int(i) => write!(f, "{i}"), + Value::StrList(v) => write!(f, "[{} items]", v.len()), + Value::MapStrStr(m) => write!(f, "{{{} entries}}", m.len()), + Value::Json(j) => write!(f, "{}", j), + Value::Request(r) => write!(f, "", std::mem::discriminant(r)), + Value::Response(r) => write!(f, "", std::mem::discriminant(r)), + Value::Skipped => write!(f, ""), + } + } +} + +impl Default for Value { + fn default() -> Self { + Value::Unit + } +} + +impl From for Value { + fn from(r: TransportRequest) -> Self { + Value::Request(r) + } +} + +impl From for Value { + fn from(r: TransportResponse) -> Self { + Value::Response(r) + } +} diff --git a/crates/gunbc-makegen/Cargo.toml b/crates/gunbc-makegen/Cargo.toml new file mode 100644 index 00000000000..03fa2a33843 --- /dev/null +++ b/crates/gunbc-makegen/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "gunbc-makegen" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Makefile generation from gunbc DAG entrypoints" + +[dependencies] +gunbc-ir = { workspace = true } +gunbc-exec = { workspace = true } +gunbc-codegen = { workspace = true } +gunbc-test = { workspace = true } +serde_json = { workspace = true } + +[[bin]] +name = "gunbc-makegen" +path = "src/main.rs" diff --git a/crates/gunbc-makegen/src/graph.rs b/crates/gunbc-makegen/src/graph.rs new file mode 100644 index 00000000000..4e208f35049 --- /dev/null +++ b/crates/gunbc-makegen/src/graph.rs @@ -0,0 +1,108 @@ +//! Graph builder for the makegen tool. + +use crate::ops::MakegenOp; +use gunbc_ir::{build::*, Dag, Edge, Node}; + +/// Build the makegen graph. +/// +/// Pipeline: +/// ```text +/// LoadRegistry -> RenderMakefile -> WriteMakefile +/// ↓ +/// (boundary) +/// ``` +/// +/// # Port Cardinalities +/// +/// - `tool_count`: One (scalar integer) +/// - `tool_names`: OneOrMore (at least one tool should exist) +/// - `registry`: One (JSON registry object) +/// - `makefile_content`: One (generated content) +/// - `output_path`: One (optional, defaults to "Makefile") +/// - `written_path`, `content`: One (results) +/// - `changed`: One (boolean flag) +pub fn build_makegen_graph() -> Dag { + let mut dag = Dag::new(); + + // Node: LoadRegistry + // No inputs (uses default registry) + // Outputs: tool metadata and registry JSON + dag.add_node(Node::opaque( + "load_registry", + vec![], + vec![ + scalar("tool_count", "Int"), + non_empty_list("tool_names", "StrList"), + scalar("registry", "Json"), + ], + MakegenOp::LoadRegistry, + )); + + // Node: RenderMakefile + // Input: registry JSON + // Output: generated Makefile content + dag.add_node(Node::opaque( + "render_makefile", + vec![scalar("registry", "Json")], + vec![scalar("makefile_content", "String")], + MakegenOp::RenderMakefile, + )); + + // Node: WriteMakefile (BOUNDARY - world write) + // Input: content and optional path + // Output: write results + dag.add_node(Node::opaque( + "write_makefile", + vec![ + scalar("makefile_content", "String"), + optional("output_path", "String"), + ], + vec![ + scalar("written_path", "String"), + scalar("content", "String"), + scalar("changed", "Bool"), + ], + MakegenOp::WriteMakefile, + )); + + // Wire up the pipeline + dag.add_edge(Edge::new("load_registry", "registry", "render_makefile", "registry")); + dag.add_edge(Edge::new("render_makefile", "makefile_content", "write_makefile", "makefile_content")); + + dag +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::{detect_boundaries, detect_entrypoints}; + + #[test] + fn test_graph_has_boundary() { + let dag = build_makegen_graph(); + let boundaries = detect_boundaries(&dag); + + // WriteMakefile should be a boundary (world write) + assert!(boundaries.is_boundary_node(&"write_makefile".into())); + // load_registry also has unconnected outputs (tool_count, tool_names) + // which are informational - that's fine, they're secondary boundaries + assert!(boundaries.boundary_nodes.len() >= 1); + } + + #[test] + fn test_graph_has_entrypoint() { + let dag = build_makegen_graph(); + let entrypoints = detect_entrypoints(&dag); + + // output_path is an entrypoint (input to write_makefile with no upstream) + assert!(entrypoints.is_entrypoint_port(&"write_makefile".into(), &"output_path".into())); + } + + #[test] + fn test_graph_structure() { + let dag = build_makegen_graph(); + + assert_eq!(dag.nodes.len(), 3); + assert_eq!(dag.edges.len(), 2); + } +} diff --git a/crates/gunbc-makegen/src/lib.rs b/crates/gunbc-makegen/src/lib.rs new file mode 100644 index 00000000000..f59c64617c1 --- /dev/null +++ b/crates/gunbc-makegen/src/lib.rs @@ -0,0 +1,23 @@ +//! gunbc-makegen: Makefile generation from DAG entrypoints. +//! +//! This crate generates Makefile targets from gunbc tool entrypoints. +//! Entrypoints are inputs with no upstream edge — they come from the world +//! and become Make variables. +//! +//! # Example Generated Makefile +//! +//! ```makefile +//! # gunbc-gist entrypoints: repo_path (String) +//! gist: +//! @cargo run -p gunbc-gist -- $(if $(REPO),--repo $(REPO)) +//! ``` + +pub mod ops; +pub mod graph; +pub mod registry; +pub mod render; + +pub use graph::build_makegen_graph; +pub use ops::MakegenOp; +pub use registry::{ToolInfo, ToolRegistry, EntrypointParam}; +pub use render::render_makefile; diff --git a/crates/gunbc-makegen/src/main.rs b/crates/gunbc-makegen/src/main.rs new file mode 100644 index 00000000000..4b2cb1ef586 --- /dev/null +++ b/crates/gunbc-makegen/src/main.rs @@ -0,0 +1,142 @@ +//! CLI for gunbc-makegen. + +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_ir::Value; +use gunbc_makegen::build_makegen_graph; +use std::env; +use std::process; + +fn main() { + let args: Vec = env::args().collect(); + + let mut output_path = "Makefile".to_string(); + let mut dry_run = false; + + // Simple argument parsing + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--output" | "-o" => { + i += 1; + if i < args.len() { + output_path = args[i].clone(); + } + } + "--dry-run" | "-n" => { + dry_run = true; + } + "--help" | "-h" => { + print_help(); + return; + } + _ => {} + } + i += 1; + } + + // Build the graph + let dag = build_makegen_graph(); + + // Set up execution mode + let mode = if dry_run { + let mut mocks = BoundaryMocks::new(); + mocks.set_value( + "write_makefile", + "written_path", + Value::Str("".to_string()), + ); + mocks.set_value( + "write_makefile", + "content", + Value::Str("".to_string()), + ); + mocks.set_value("write_makefile", "changed", Value::Bool(true)); + ExecutionMode::DryRun(mocks) + } else { + ExecutionMode::Real + }; + + println!("gunbc-makegen"); + println!(" output: {}", output_path); + println!(" mode: {}", if dry_run { "dry-run" } else { "real" }); + println!(); + + match execute_with_mode(&dag, mode) { + Ok(log) => { + for entry in &log.entries { + let marker = if entry.was_intercepted { + " [DRY-RUN]" + } else { + "" + }; + println!("[{}]{}", entry.node_id, marker); + + // Print relevant outputs + for (port, value) in &entry.outputs { + match value { + Value::Str(s) if s.len() < 100 => println!(" {}: {}", port, s), + Value::Str(s) if port == "makefile_content" || port == "content" => { + if !entry.was_intercepted { + println!(" {}: ", port); + println!("--- START ---"); + println!("{}", s); + println!("--- END ---"); + } + } + Value::Str(s) => println!(" {}: {}...", port, &s[..50]), + Value::StrList(list) => println!(" {}: [{} items]", port, list.len()), + Value::Int(n) => println!(" {}: {}", port, n), + Value::Bool(b) => println!(" {}: {}", port, b), + _ => {} + } + } + } + + // Show rendered content in dry-run mode + if dry_run { + if let Some(entry) = log.get("render_makefile") { + if let Some(Value::Str(content)) = entry.outputs.get("makefile_content") { + println!(); + println!("Generated Makefile:"); + println!("--- START ---"); + println!("{}", content); + println!("--- END ---"); + } + } + } + + // Final status + if let Some(entry) = log.get("write_makefile") { + println!(); + if entry.was_intercepted { + println!("Would have written to: {}", output_path); + } else { + if let Some(Value::Str(path)) = entry.outputs.get("written_path") { + println!("Written to: {}", path); + } + } + } + } + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } +} + +fn print_help() { + println!("gunbc-makegen - Generate Makefile from tool registry"); + println!(); + println!("USAGE:"); + println!(" gunbc-makegen [OPTIONS]"); + println!(); + println!("OPTIONS:"); + println!(" -o, --output Output Makefile path (default: Makefile)"); + println!(" -n, --dry-run Don't actually write the file"); + println!(" -h, --help Print this help message"); + println!(); + println!("EXAMPLES:"); + println!(" gunbc-makegen # Generate Makefile"); + println!(" gunbc-makegen --dry-run # Preview without writing"); + println!(" gunbc-makegen -o build/Makefile # Custom output path"); +} diff --git a/crates/gunbc-makegen/src/ops.rs b/crates/gunbc-makegen/src/ops.rs new file mode 100644 index 00000000000..822c50fc850 --- /dev/null +++ b/crates/gunbc-makegen/src/ops.rs @@ -0,0 +1,236 @@ +//! Makegen operations. + +use gunbc_codegen::FileWriter; +use gunbc_exec::{ExecError, Executable}; +use gunbc_ir::Value; +use std::collections::HashMap; + +use crate::registry::ToolRegistry; +use crate::render::render_makefile; + +/// Operations for the makegen tool. +#[derive(Debug, Clone)] +pub enum MakegenOp { + /// Load the tool registry + LoadRegistry, + /// Render Makefile content + RenderMakefile, + /// Write Makefile to disk (boundary) + WriteMakefile, +} + +impl Executable for MakegenOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + match self { + MakegenOp::LoadRegistry => execute_load_registry(inputs), + MakegenOp::RenderMakefile => execute_render_makefile(inputs), + MakegenOp::WriteMakefile => execute_write_makefile(inputs), + } + } +} + +/// Load the default tool registry. +fn execute_load_registry(_inputs: HashMap) -> Result, ExecError> { + let registry = ToolRegistry::default_registry(); + + // Serialize tool names for downstream + let tool_names: Vec = registry.tools.iter().map(|t| t.short_name.clone()).collect(); + + let mut out = HashMap::new(); + out.insert("tool_count".to_string(), Value::Int(registry.tools.len() as i64)); + out.insert("tool_names".to_string(), Value::StrList(tool_names)); + // Store registry as JSON for downstream + let registry_json = serde_json::json!({ + "tools": registry.tools.iter().map(|t| { + serde_json::json!({ + "crate_name": t.crate_name, + "short_name": t.short_name, + "description": t.description, + "entrypoints": t.entrypoints.iter().map(|e| { + serde_json::json!({ + "port_name": e.port_name, + "make_var": e.make_var, + "cli_flag": e.cli_flag, + "type_hint": e.type_hint, + "default": e.default, + "repeatable": e.repeatable, + }) + }).collect::>() + }) + }).collect::>() + }); + out.insert("registry".to_string(), Value::Json(registry_json)); + Ok(out) +} + +/// Render the Makefile content. +fn execute_render_makefile(_inputs: HashMap) -> Result, ExecError> { + // For now, use the default registry directly + // In a more sophisticated version, we'd deserialize from inputs + let registry = ToolRegistry::default_registry(); + let content = render_makefile(®istry); + + let mut out = HashMap::new(); + out.insert("makefile_content".to_string(), Value::Str(content)); + Ok(out) +} + +/// Write the Makefile to disk. +fn execute_write_makefile(inputs: HashMap) -> Result, ExecError> { + let content = match inputs.get("makefile_content") { + Some(Value::Str(s)) => s.clone(), + _ => return Err(ExecError::new("missing or invalid 'makefile_content' input")), + }; + + let output_path = match inputs.get("output_path") { + Some(Value::Str(s)) => s.clone(), + _ => "Makefile".to_string(), + }; + + let writer = FileWriter::real(); + let result = writer + .write(&output_path, &content) + .map_err(|e| ExecError::new(format!("failed to write Makefile: {}", e)))?; + + let mut out = HashMap::new(); + out.insert("written_path".to_string(), Value::Str(result.path)); + out.insert("content".to_string(), Value::Str(content)); + out.insert("changed".to_string(), Value::Bool(result.changed)); + Ok(out) +} + +// ============================================================================ +// Mockable trait implementation +// ============================================================================ + +use gunbc_test::{CardinalityTestInput, ErrorTestCase, Mockable}; + +impl Mockable for MakegenOp { + fn mock_outputs(&self) -> HashMap { + match self { + MakegenOp::LoadRegistry => { + let mut out = HashMap::new(); + out.insert("tool_count".to_string(), Value::Int(3)); + out.insert( + "tool_names".to_string(), + Value::StrList(vec![ + "gist".to_string(), + "deps".to_string(), + "buck2".to_string(), + ]), + ); + out.insert( + "registry".to_string(), + Value::Json(serde_json::json!({ + "tools": [ + {"crate_name": "gunbc-gist", "short_name": "gist"}, + {"crate_name": "gunbc-deps", "short_name": "deps"}, + {"crate_name": "gunbc-buck2", "short_name": "buck2"}, + ] + })), + ); + out + } + MakegenOp::RenderMakefile => { + let mut out = HashMap::new(); + out.insert( + "makefile_content".to_string(), + Value::Str( + r#"# Generated Makefile +.PHONY: gist deps buck2 + +gist: + cargo run -p gunbc-gist + +deps: + cargo run -p gunbc-deps + +buck2: + cargo run -p gunbc-buck2 +"# + .to_string(), + ), + ); + out + } + MakegenOp::WriteMakefile => { + let mut out = HashMap::new(); + out.insert("written_path".to_string(), Value::Str("Makefile".to_string())); + out.insert( + "content".to_string(), + Value::Str("# Mock Makefile content".to_string()), + ); + out.insert("changed".to_string(), Value::Bool(true)); + out + } + } + } + + fn cardinality_inputs(&self) -> Vec { + // MakegenOp doesn't have list inputs that need cardinality testing + vec![] + } + + fn error_cases(&self) -> Vec { + match self { + MakegenOp::LoadRegistry => vec![ + // LoadRegistry doesn't require inputs, so no error cases + ], + MakegenOp::RenderMakefile => vec![ + // RenderMakefile doesn't require inputs currently + ], + MakegenOp::WriteMakefile => vec![ + ErrorTestCase::new( + "missing_makefile_content", + HashMap::new(), + "missing or invalid 'makefile_content' input", + ), + ErrorTestCase::new( + "wrong_type_makefile_content", + { + let mut m = HashMap::new(); + m.insert("makefile_content".to_string(), Value::Int(123)); + m + }, + "missing or invalid 'makefile_content' input", + ), + ], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_load_registry() { + let result = execute_load_registry(HashMap::new()).unwrap(); + + match result.get("tool_count") { + Some(Value::Int(n)) => assert!(*n >= 2), + _ => panic!("expected tool count"), + } + + match result.get("tool_names") { + Some(Value::StrList(names)) => { + assert!(names.contains(&"gist".to_string())); + assert!(names.contains(&"buck2".to_string())); + } + _ => panic!("expected tool names"), + } + } + + #[test] + fn test_render_makefile() { + let result = execute_render_makefile(HashMap::new()).unwrap(); + + match result.get("makefile_content") { + Some(Value::Str(content)) => { + assert!(content.contains("gist:")); + assert!(content.contains("buck2:")); + } + _ => panic!("expected makefile content"), + } + } +} diff --git a/crates/gunbc-makegen/src/registry.rs b/crates/gunbc-makegen/src/registry.rs new file mode 100644 index 00000000000..5c21557445e --- /dev/null +++ b/crates/gunbc-makegen/src/registry.rs @@ -0,0 +1,246 @@ +//! Tool registry for makegen. +//! +//! Defines what tools exist and their entrypoint parameters. + +/// Information about a gunbc tool. +#[derive(Debug, Clone)] +pub struct ToolInfo { + /// Crate name (e.g., "gunbc-gist") + pub crate_name: String, + /// Short name for make target (e.g., "gist") + pub short_name: String, + /// Description for help text + pub description: String, + /// Entrypoint parameters (from DAG entrypoints) + pub entrypoints: Vec, + /// Extra composite targets (e.g., "viz-serve" that runs viz + starts server) + pub extra_targets: Vec, +} + +/// An extra target that combines the main tool with additional commands. +#[derive(Debug, Clone)] +pub struct ExtraTarget { + /// Target name suffix (e.g., "serve" becomes "viz-serve") + pub suffix: String, + /// Description for help text + pub description: String, + /// Shell commands to run after the main tool + pub post_commands: Vec, +} + +impl ToolInfo { + /// Create a new tool info. + pub fn new( + crate_name: impl Into, + short_name: impl Into, + description: impl Into, + ) -> Self { + Self { + crate_name: crate_name.into(), + short_name: short_name.into(), + description: description.into(), + entrypoints: Vec::new(), + extra_targets: Vec::new(), + } + } + + /// Add an entrypoint parameter. + pub fn with_param(mut self, param: EntrypointParam) -> Self { + self.entrypoints.push(param); + self + } + + /// Add an extra composite target. + pub fn with_extra_target(mut self, target: ExtraTarget) -> Self { + self.extra_targets.push(target); + self + } +} + +impl ExtraTarget { + /// Create a new extra target. + pub fn new(suffix: impl Into, description: impl Into) -> Self { + Self { + suffix: suffix.into(), + description: description.into(), + post_commands: Vec::new(), + } + } + + /// Add a post command. + pub fn with_command(mut self, cmd: impl Into) -> Self { + self.post_commands.push(cmd.into()); + self + } +} + +/// An entrypoint parameter that becomes a Make variable. +#[derive(Debug, Clone)] +pub struct EntrypointParam { + /// DAG port name (e.g., "repo_path") + pub port_name: String, + /// Make variable name (e.g., "REPO") + pub make_var: String, + /// CLI flag (e.g., "--repo") + pub cli_flag: String, + /// Type hint for help text + pub type_hint: String, + /// Default value if any + pub default: Option, + /// Whether this param can be repeated (for list types) + pub repeatable: bool, +} + +impl EntrypointParam { + /// Create a new entrypoint parameter. + pub fn new( + port_name: impl Into, + make_var: impl Into, + cli_flag: impl Into, + type_hint: impl Into, + ) -> Self { + Self { + port_name: port_name.into(), + make_var: make_var.into(), + cli_flag: cli_flag.into(), + type_hint: type_hint.into(), + default: None, + repeatable: false, + } + } + + /// Set a default value. + pub fn with_default(mut self, default: impl Into) -> Self { + self.default = Some(default.into()); + self + } + + /// Mark as repeatable (for list parameters). + pub fn repeatable(mut self) -> Self { + self.repeatable = true; + self + } +} + +/// Registry of all gunbc tools. +#[derive(Debug, Default)] +pub struct ToolRegistry { + pub tools: Vec, +} + +impl ToolRegistry { + /// Create a new empty registry. + pub fn new() -> Self { + Self { tools: Vec::new() } + } + + /// Add a tool to the registry. + pub fn register(&mut self, tool: ToolInfo) { + self.tools.push(tool); + } + + /// Build the default registry with all known gunbc tools. + pub fn default_registry() -> Self { + let mut registry = Self::new(); + + // gunbc-gist + registry.register( + ToolInfo::new("gunbc-gist", "gist", "Create a GitHub gist from code files") + .with_param( + EntrypointParam::new("repo_path", "REPO", "--repo", "String") + .with_default("."), + ) + .with_param( + EntrypointParam::new("extensions", "EXT", "-e", "String") + .repeatable(), + ), + ); + + // gunbc-buck2 + registry.register( + ToolInfo::new("gunbc-buck2", "buck2", "Generate BUCK file from Cargo.toml") + .with_param( + EntrypointParam::new("cargo_toml_path", "INPUT", "--input", "String") + .with_default("Cargo.toml"), + ) + .with_param( + EntrypointParam::new("output_path", "OUTPUT", "--output", "String") + .with_default("BUCK"), + ), + ); + + // gunbc-makegen (self!) + registry.register( + ToolInfo::new("gunbc-makegen", "makegen", "Generate Makefile from tool registry") + .with_param( + EntrypointParam::new("output_path", "OUTPUT", "--output", "String") + .with_default("Makefile"), + ), + ); + + // gunbc-deps + registry.register( + ToolInfo::new("gunbc-deps", "deps", "Install tool dependencies") + .with_param( + EntrypointParam::new("manifest_path", "MANIFEST", "--manifest", "String") + .with_default("deps.toml"), + ), + ); + + // gunbc-ci + registry.register( + ToolInfo::new("gunbc-ci", "ci", "Run CI pipeline"), + ); + + // gunbc-bootstrap + registry.register( + ToolInfo::new("gunbc-bootstrap", "bootstrap", "Generate Makefile and .gitignore"), + ); + + // gunbc-viz + registry.register( + ToolInfo::new("gunbc-viz", "viz", "Generate DAG visualization data") + .with_param( + EntrypointParam::new("output_path", "OUTPUT", "--output", "String") + .with_default("viz-data.json"), + ) + .with_extra_target( + ExtraTarget::new("serve", "Generate viz data and open in browser") + .with_command("@echo \"Starting server at http://localhost:8080/viz.html\"") + .with_command("@(sleep 1 && python3 -c \"import webbrowser; webbrowser.open('http://localhost:8080/viz.html')\") &") + .with_command("@python3 -m http.server 8080"), + ), + ); + + registry + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_registry_has_tools() { + let registry = ToolRegistry::default_registry(); + assert!(registry.tools.len() >= 2); + + let gist = registry.tools.iter().find(|t| t.short_name == "gist"); + assert!(gist.is_some()); + + let buck2 = registry.tools.iter().find(|t| t.short_name == "buck2"); + assert!(buck2.is_some()); + } + + #[test] + fn test_tool_has_entrypoints() { + let registry = ToolRegistry::default_registry(); + let gist = registry.tools.iter().find(|t| t.short_name == "gist").unwrap(); + + assert!(!gist.entrypoints.is_empty()); + + let repo_param = gist.entrypoints.iter().find(|p| p.port_name == "repo_path"); + assert!(repo_param.is_some()); + assert_eq!(repo_param.unwrap().make_var, "REPO"); + } +} diff --git a/crates/gunbc-makegen/src/render.rs b/crates/gunbc-makegen/src/render.rs new file mode 100644 index 00000000000..9a19586c090 --- /dev/null +++ b/crates/gunbc-makegen/src/render.rs @@ -0,0 +1,184 @@ +//! Makefile rendering. + +use crate::registry::{EntrypointParam, ExtraTarget, ToolInfo, ToolRegistry}; + +/// Render a complete Makefile from the tool registry. +pub fn render_makefile(registry: &ToolRegistry) -> String { + let mut output = String::new(); + + // Header + output.push_str("# Generated by gunbc-makegen\n"); + output.push_str("# DO NOT EDIT - regenerate with: make makegen\n\n"); + + // Default goal + output.push_str(".DEFAULT_GOAL := help\n\n"); + + // Phony targets + output.push_str(".PHONY: help"); + for tool in ®istry.tools { + output.push_str(&format!(" {} {}-dry", tool.short_name, tool.short_name)); + for extra in &tool.extra_targets { + output.push_str(&format!(" {}-{}", tool.short_name, extra.suffix)); + } + } + output.push_str("\n\n"); + + // Help target + output.push_str("help:\n"); + output.push_str("\t@echo \"gunbc tools - generated Makefile\"\n"); + output.push_str("\t@echo \"\"\n"); + output.push_str("\t@echo \"Available targets:\"\n"); + for tool in ®istry.tools { + let params = render_help_params(&tool.entrypoints); + output.push_str(&format!( + "\t@echo \" {} {} - {}\"\n", + tool.short_name, params, tool.description + )); + for extra in &tool.extra_targets { + output.push_str(&format!( + "\t@echo \" {}-{} - {}\"\n", + tool.short_name, extra.suffix, extra.description + )); + } + } + output.push_str("\t@echo \"\"\n"); + output.push_str("\t@echo \"Add -dry suffix for dry-run (e.g., make gist-dry)\"\n\n"); + + // Tool targets + for tool in ®istry.tools { + output.push_str(&render_tool_target(tool)); + output.push_str(&render_dry_run_target(tool)); + for extra in &tool.extra_targets { + output.push_str(&render_extra_target(tool, extra)); + } + } + + output +} + +/// Render help text for parameters. +fn render_help_params(params: &[EntrypointParam]) -> String { + params + .iter() + .map(|p| { + if let Some(ref default) = p.default { + format!("[{}={}]", p.make_var, default) + } else { + format!("[{}=...]", p.make_var) + } + }) + .collect::>() + .join(" ") +} + +/// Render a tool target. +fn render_tool_target(tool: &ToolInfo) -> String { + let mut output = String::new(); + + // Comment with entrypoint info + let port_list = tool + .entrypoints + .iter() + .map(|p| format!("{} ({})", p.port_name, p.type_hint)) + .collect::>() + .join(", "); + output.push_str(&format!("# {} entrypoints: {}\n", tool.crate_name, port_list)); + + // Target + output.push_str(&format!("{}:\n", tool.short_name)); + output.push_str(&format!( + "\t@cargo run -p {} --{}", + tool.crate_name, + render_cli_args(&tool.entrypoints) + )); + output.push_str("\n\n"); + + output +} + +/// Render a dry-run target. +fn render_dry_run_target(tool: &ToolInfo) -> String { + let mut output = String::new(); + + output.push_str(&format!("{}-dry:\n", tool.short_name)); + output.push_str(&format!( + "\t@cargo run -p {} -- --dry-run{}", + tool.crate_name, + render_cli_args(&tool.entrypoints) + )); + output.push_str("\n\n"); + + output +} + +/// Render an extra composite target. +fn render_extra_target(tool: &ToolInfo, extra: &ExtraTarget) -> String { + let mut output = String::new(); + + output.push_str(&format!( + "# {}-{}: {}\n", + tool.short_name, extra.suffix, extra.description + )); + output.push_str(&format!("{}-{}: {}\n", tool.short_name, extra.suffix, tool.short_name)); + + // Add post commands + for cmd in &extra.post_commands { + output.push_str(&format!("\t{}\n", cmd)); + } + output.push_str("\n"); + + output +} + +/// Render CLI arguments from entrypoint parameters. +fn render_cli_args(params: &[EntrypointParam]) -> String { + if params.is_empty() { + return String::new(); + } + + let args: Vec = params + .iter() + .map(|p| { + // $(if $(VAR),--flag $(VAR)) + format!(" $(if $({}),{} $({}))", p.make_var, p.cli_flag, p.make_var) + }) + .collect(); + + args.join("") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_render_makefile_has_header() { + let registry = ToolRegistry::default_registry(); + let makefile = render_makefile(®istry); + + assert!(makefile.contains("Generated by gunbc-makegen")); + assert!(makefile.contains(".PHONY:")); + assert!(makefile.contains("help:")); + } + + #[test] + fn test_render_makefile_has_targets() { + let registry = ToolRegistry::default_registry(); + let makefile = render_makefile(®istry); + + assert!(makefile.contains("gist:")); + assert!(makefile.contains("gist-dry:")); + assert!(makefile.contains("buck2:")); + assert!(makefile.contains("buck2-dry:")); + } + + #[test] + fn test_render_makefile_has_cli_args() { + let registry = ToolRegistry::default_registry(); + let makefile = render_makefile(®istry); + + // Should have conditional variable expansion + assert!(makefile.contains("$(if $(REPO)")); + assert!(makefile.contains("--repo")); + } +} diff --git a/crates/gunbc-test/Cargo.toml b/crates/gunbc-test/Cargo.toml new file mode 100644 index 00000000000..4d6f017d3a6 --- /dev/null +++ b/crates/gunbc-test/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "gunbc-test" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Test infrastructure for gunbc: boundary mocks and composition tests" + +[dependencies] +gunbc-ir = { workspace = true } +gunbc-exec = { workspace = true } diff --git a/crates/gunbc-test/src/boundary.rs b/crates/gunbc-test/src/boundary.rs new file mode 100644 index 00000000000..486b683cec8 --- /dev/null +++ b/crates/gunbc-test/src/boundary.rs @@ -0,0 +1,138 @@ +//! Boundary test helpers. +//! +//! Boundary tests verify that a DAG's world-write boundaries can be mocked. + +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_ir::{detect_boundaries, Dag, Value}; + +/// Result of a boundary test. +#[derive(Debug)] +pub struct BoundaryTestResult { + /// Whether the test passed + pub success: bool, + /// Boundary nodes that were tested + pub boundary_nodes: Vec, + /// Any error message + pub error: Option, +} + +impl BoundaryTestResult { + /// Check if the test passed. + pub fn is_ok(&self) -> bool { + self.success + } +} + +/// Assert that a DAG's boundaries can be mocked in dry-run mode. +/// +/// This test verifies that: +/// 1. The DAG has identifiable boundaries +/// 2. Executing in dry-run mode succeeds +/// 3. All boundary nodes were intercepted +/// +/// # Example +/// +/// ```ignore +/// let dag = build_gist_graph(); +/// let result = assert_boundary_mockable(&dag, default_mocks()); +/// assert!(result.is_ok()); +/// ``` +pub fn assert_boundary_mockable( + dag: &Dag, + mocks: BoundaryMocks, +) -> BoundaryTestResult { + // Detect boundaries + let boundaries = detect_boundaries(dag); + + if boundaries.boundary_nodes.is_empty() { + return BoundaryTestResult { + success: false, + boundary_nodes: vec![], + error: Some("DAG has no boundaries — nothing to test".to_string()), + }; + } + + let boundary_nodes: Vec = boundaries.boundary_nodes.iter().map(|n| n.0.clone()).collect(); + + // Execute in dry-run mode + match execute_with_mode(dag, ExecutionMode::DryRun(mocks)) { + Ok(log) => { + // Verify all boundaries were intercepted + let mut all_intercepted = true; + for node_id in &boundary_nodes { + if let Some(entry) = log.get(node_id) { + if !entry.was_intercepted { + all_intercepted = false; + } + } + } + + if all_intercepted { + BoundaryTestResult { + success: true, + boundary_nodes, + error: None, + } + } else { + BoundaryTestResult { + success: false, + boundary_nodes, + error: Some("Not all boundary nodes were intercepted".to_string()), + } + } + } + Err(e) => BoundaryTestResult { + success: false, + boundary_nodes, + error: Some(format!("Execution failed: {}", e)), + }, + } +} + +/// Create default mocks for boundary testing. +/// +/// Returns mocks that produce a default "" string for all boundary ports. +pub fn default_mocks() -> BoundaryMocks { + BoundaryMocks::with_default(Value::Str("".to_string())) +} + +/// Create mocks with specific values for known boundary ports. +pub fn mocks_with_values(values: impl IntoIterator) -> BoundaryMocks { + let mut mocks = BoundaryMocks::new(); + for (node, port, value) in values { + mocks.set_value(node, port, value); + } + mocks +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mock::MockOp; + use gunbc_ir::build::*; + use gunbc_ir::Node; + + #[test] + fn test_boundary_mockable_passes() { + let mut dag: Dag = Dag::new(); + dag.add_node(Node::opaque( + "sink", + vec![], + vec![port("out", "S")], + MockOp::new("sink", [("out", Value::Str("real".to_string()))]), + )); + + let result = assert_boundary_mockable(&dag, default_mocks()); + assert!(result.is_ok()); + assert_eq!(result.boundary_nodes, vec!["sink"]); + } + + #[test] + fn test_no_boundaries_fails() { + // Empty DAG has no boundaries + let dag: Dag = Dag::new(); + let result = assert_boundary_mockable(&dag, default_mocks()); + assert!(!result.is_ok()); + assert!(result.error.unwrap().contains("no boundaries")); + } +} diff --git a/crates/gunbc-test/src/composition.rs b/crates/gunbc-test/src/composition.rs new file mode 100644 index 00000000000..b8d37010abd --- /dev/null +++ b/crates/gunbc-test/src/composition.rs @@ -0,0 +1,149 @@ +//! Composition test helpers. +//! +//! Composition tests verify that edges between nodes have compatible types. + +use gunbc_ir::{Dag, TypeId}; + +/// Result of type compatibility check. +#[derive(Debug)] +pub struct TypeCompatibility { + /// Whether the types are compatible + pub compatible: bool, + /// Source type + pub from_type: TypeId, + /// Target type + pub to_type: TypeId, + /// Edge description + pub edge: String, +} + +impl TypeCompatibility { + /// Check if types are compatible. + pub fn is_compatible(&self) -> bool { + self.compatible + } +} + +/// Check if two types are compatible. +/// +/// For now, types are compatible if they are equal. +/// This could be extended to support subtyping or coercion. +pub fn types_compatible(from: &TypeId, to: &TypeId) -> bool { + // Exact match + if from.0 == to.0 { + return true; + } + + // "Any" is compatible with anything + if from.0 == "Any" || to.0 == "Any" { + return true; + } + + false +} + +/// Assert that all edges in a DAG have compatible types. +/// +/// Returns a list of compatibility results for each edge. +pub fn assert_types_compatible(dag: &Dag) -> Vec { + let mut results = Vec::new(); + + for edge in &dag.edges { + // Find the source node and port + let from_node = dag.get_node(&edge.from_node); + let to_node = dag.get_node(&edge.to_node); + + let (from_type, to_type) = match (from_node, to_node) { + (Some(from), Some(to)) => { + let from_port = from.outputs.iter().find(|p| p.name == edge.from_port); + let to_port = to.inputs.iter().find(|p| p.name == edge.to_port); + + match (from_port, to_port) { + (Some(fp), Some(tp)) => (fp.type_id.clone(), tp.type_id.clone()), + _ => continue, // Skip if ports not found + } + } + _ => continue, // Skip if nodes not found + }; + + let compatible = types_compatible(&from_type, &to_type); + + results.push(TypeCompatibility { + compatible, + from_type, + to_type, + edge: format!( + "{}.{} -> {}.{}", + edge.from_node.0, edge.from_port.0, edge.to_node.0, edge.to_port.0 + ), + }); + } + + results +} + +/// Check that all edges are compatible, panicking if not. +pub fn verify_all_edges_compatible(dag: &Dag) { + let results = assert_types_compatible(dag); + let mut failed = Vec::new(); + + for result in &results { + if !result.is_compatible() { + failed.push(format!( + "{}: {} -> {} (incompatible)", + result.edge, result.from_type, result.to_type + )); + } + } + + if !failed.is_empty() { + panic!( + "Type compatibility check failed for {} edge(s):\n {}", + failed.len(), + failed.join("\n ") + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::build::*; + use gunbc_ir::{Node, Dag}; + + #[test] + fn test_compatible_types() { + let mut dag: Dag<()> = Dag::new(); + dag.add_node(Node::opaque("A", vec![], vec![port("out", "String")], ())); + dag.add_node(Node::opaque("B", vec![port("in", "String")], vec![], ())); + dag.add_edge(edge("A", "out", "B", "in")); + + let results = assert_types_compatible(&dag); + assert_eq!(results.len(), 1); + assert!(results[0].is_compatible()); + } + + #[test] + fn test_incompatible_types() { + let mut dag: Dag<()> = Dag::new(); + dag.add_node(Node::opaque("A", vec![], vec![port("out", "String")], ())); + dag.add_node(Node::opaque("B", vec![port("in", "Int")], vec![], ())); + dag.add_edge(edge("A", "out", "B", "in")); + + let results = assert_types_compatible(&dag); + assert_eq!(results.len(), 1); + assert!(!results[0].is_compatible()); + } + + #[test] + fn test_any_type_compatible() { + let mut dag: Dag<()> = Dag::new(); + dag.add_node(Node::opaque("A", vec![], vec![port("out", "String")], ())); + dag.add_node(Node::opaque("B", vec![port("in", "Any")], vec![], ())); + dag.add_edge(edge("A", "out", "B", "in")); + + let results = assert_types_compatible(&dag); + assert_eq!(results.len(), 1); + assert!(results[0].is_compatible()); + } +} diff --git a/crates/gunbc-test/src/lib.rs b/crates/gunbc-test/src/lib.rs new file mode 100644 index 00000000000..9118cae060a --- /dev/null +++ b/crates/gunbc-test/src/lib.rs @@ -0,0 +1,39 @@ +//! gunbc-test: Test infrastructure for gunbc DAGs. +//! +//! This crate provides: +//! - [`MockOp`]: A mock operation for testing DAGs without real implementations +//! - [`ScriptedDagBuilder`]: Build DAGs with scripted mock behaviors +//! - [`assert_boundary_mockable`]: Verify that a DAG's boundaries can be mocked +//! - [`assert_types_compatible`]: Verify edge type compatibility +//! - [`Mockable`]: Trait for operations to provide test fixtures +//! +//! # Boundary Tests +//! +//! Every DAG should be testable in dry-run mode. Boundary tests verify that +//! all world-write boundaries can be intercepted with mock values. +//! +//! # Composition Tests +//! +//! Edges between nodes must have compatible types. Composition tests verify +//! that the output type of one node matches the input type of the connected node. +//! +//! # Mockable Trait +//! +//! Operations can implement the [`Mockable`] trait to provide: +//! - Default mock outputs for dry-run testing +//! - Cardinality test inputs (empty, one, many) for edge case testing +//! - Error cases for failure testing +//! +//! This enables automatic test generation that provides real signal. + +pub mod boundary; +pub mod composition; +pub mod mock; +pub mod mockable; + +pub use boundary::{assert_boundary_mockable, default_mocks, mocks_with_values, BoundaryTestResult}; +pub use composition::{assert_types_compatible, TypeCompatibility}; +pub use mock::{MockBehavior, MockOp, ScriptedDagBuilder}; +pub use mockable::{ + CardinalityTestInput, ErrorTestCase, ExpectedBehavior, Mockable, +}; diff --git a/crates/gunbc-test/src/mock.rs b/crates/gunbc-test/src/mock.rs new file mode 100644 index 00000000000..aa59240289c --- /dev/null +++ b/crates/gunbc-test/src/mock.rs @@ -0,0 +1,210 @@ +//! Mock operations for testing. + +use gunbc_exec::{ExecError, Executable}; +use gunbc_ir::{Dag, Node, NodeBody, Value}; +use std::collections::HashMap; +use std::fmt; +use std::sync::Arc; + +/// Mock behavior: either scripted outputs or a function. +#[derive(Clone)] +pub enum MockBehavior { + /// Return fixed outputs + Scripted(HashMap), + /// Compute outputs from inputs + Func(Arc) -> Result, ExecError> + Send + Sync>), +} + +impl MockBehavior { + /// Create scripted behavior with fixed outputs. + pub fn scripted(outputs: impl IntoIterator, Value)>) -> Self { + Self::Scripted(outputs.into_iter().map(|(k, v)| (k.into(), v)).collect()) + } + + /// Create functional behavior. + pub fn func(f: F) -> Self + where + F: Fn(HashMap) -> Result, ExecError> + Send + Sync + 'static, + { + Self::Func(Arc::new(f)) + } +} + +impl fmt::Debug for MockBehavior { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MockBehavior::Scripted(outputs) => f.debug_tuple("Scripted").field(outputs).finish(), + MockBehavior::Func(_) => f.debug_tuple("Func").field(&"").finish(), + } + } +} + +/// A mock operation for testing. +#[derive(Clone)] +pub struct MockOp { + node_id: String, + behavior: MockBehavior, +} + +impl MockOp { + /// Create a new mock operation with scripted outputs. + pub fn new(node_id: impl Into, outputs: impl IntoIterator, Value)>) -> Self { + Self { + node_id: node_id.into(), + behavior: MockBehavior::scripted(outputs), + } + } + + /// Create a mock operation with functional behavior. + pub fn with_func(node_id: impl Into, f: F) -> Self + where + F: Fn(HashMap) -> Result, ExecError> + Send + Sync + 'static, + { + Self { + node_id: node_id.into(), + behavior: MockBehavior::func(f), + } + } +} + +impl fmt::Debug for MockOp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MockOp") + .field("node_id", &self.node_id) + .field("behavior", &self.behavior) + .finish() + } +} + +impl Executable for MockOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + match &self.behavior { + MockBehavior::Scripted(outputs) => Ok(outputs.clone()), + MockBehavior::Func(f) => f(inputs), + } + } +} + +/// Builder for creating DAGs with scripted mock behaviors. +pub struct ScriptedDagBuilder<'a, T> { + dag: &'a Dag, + behaviors: HashMap, +} + +impl<'a, T> ScriptedDagBuilder<'a, T> { + /// Create a new builder from an existing DAG. + pub fn new(dag: &'a Dag) -> Self { + Self { + dag, + behaviors: HashMap::new(), + } + } + + /// Set scripted outputs for a node. + pub fn with_outputs(mut self, node_id: &str, outputs: impl IntoIterator, Value)>) -> Self { + self.behaviors.insert(node_id.to_string(), MockBehavior::scripted(outputs)); + self + } + + /// Set functional behavior for a node. + pub fn with_func(mut self, node_id: &str, f: F) -> Self + where + F: Fn(HashMap) -> Result, ExecError> + Send + Sync + 'static, + { + self.behaviors.insert(node_id.to_string(), MockBehavior::func(f)); + self + } + + /// Build the mock DAG. + pub fn build(self) -> Result, String> { + let mut nodes = Vec::with_capacity(self.dag.nodes.len()); + + for node in &self.dag.nodes { + let behavior = self + .behaviors + .get(&node.id.0) + .cloned() + .ok_or_else(|| format!("missing mock behavior for node '{}'", node.id.0))?; + + let body = match &node.body { + NodeBody::Opaque(_) => NodeBody::Opaque(MockOp { + node_id: node.id.0.clone(), + behavior, + }), + NodeBody::SubDag(_) => { + // For now, don't support sub-DAGs in mock builder + return Err(format!( + "node '{}' is a SubDag — mock builder doesn't support sub-DAGs yet", + node.id.0 + )); + } + }; + + nodes.push(Node { + id: node.id.clone(), + inputs: node.inputs.clone(), + outputs: node.outputs.clone(), + body, + }); + } + + Ok(Dag { + nodes, + edges: self.dag.edges.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_exec::execute; + use gunbc_ir::build::*; + + #[test] + fn test_mock_op_scripted() { + let op = MockOp::new("test", [("out", Value::Str("hello".to_string()))]); + let result = op.execute(HashMap::new()).unwrap(); + + match result.get("out") { + Some(Value::Str(s)) => assert_eq!(s, "hello"), + _ => panic!("expected string output"), + } + } + + #[test] + fn test_mock_op_func() { + let op = MockOp::with_func("test", |inputs| { + let val = inputs.get("in").cloned().unwrap_or(Value::Unit); + let mut out = HashMap::new(); + out.insert("out".to_string(), val); + Ok(out) + }); + + let mut inputs = HashMap::new(); + inputs.insert("in".to_string(), Value::Str("echo".to_string())); + let result = op.execute(inputs).unwrap(); + + match result.get("out") { + Some(Value::Str(s)) => assert_eq!(s, "echo"), + _ => panic!("expected string output"), + } + } + + #[test] + fn test_scripted_dag_builder() { + let mut dag: Dag<()> = Dag::new(); + dag.add_node(Node::opaque("A", vec![], vec![port("out", "S")], ())); + dag.add_node(Node::opaque("B", vec![port("in", "S")], vec![port("out", "S")], ())); + dag.add_edge(edge("A", "out", "B", "in")); + + let mock_dag = ScriptedDagBuilder::new(&dag) + .with_outputs("A", [("out", Value::Str("from-A".to_string()))]) + .with_outputs("B", [("out", Value::Str("from-B".to_string()))]) + .build() + .unwrap(); + + let log = execute(&mock_dag).unwrap(); + assert_eq!(log.entries.len(), 2); + } +} diff --git a/crates/gunbc-test/src/mockable.rs b/crates/gunbc-test/src/mockable.rs new file mode 100644 index 00000000000..1eac134aef3 --- /dev/null +++ b/crates/gunbc-test/src/mockable.rs @@ -0,0 +1,252 @@ +//! Mockable trait for operations that can provide test fixtures. +//! +//! The `Mockable` trait allows operations to provide: +//! - Default mock outputs for dry-run testing +//! - Cardinality test inputs for edge case testing +//! - Error cases for failure testing +//! +//! This enables automatic test generation based on the operation's declaration. + +use gunbc_ir::{CardinalityCase, Value}; +use std::collections::HashMap; + +/// A trait for operations that can provide test fixtures. +/// +/// Operations that implement this trait can be used with the test generator +/// to automatically produce meaningful tests based on their declared behaviors. +/// +/// # Example +/// +/// ```ignore +/// impl Mockable for GistOp { +/// fn mock_outputs(&self) -> HashMap { +/// match self { +/// GistOp::FilterFiles { .. } => hashmap! { +/// "files" => Value::StrList(vec!["test.rs".into()]) +/// }, +/// // ... other variants +/// } +/// } +/// } +/// ``` +pub trait Mockable { + /// Provide default mock outputs for this operation. + /// + /// These outputs are used when testing the DAG in dry-run mode. + /// The mock outputs should be realistic values that represent + /// typical successful execution. + fn mock_outputs(&self) -> HashMap; + + /// Provide cardinality test inputs for this operation. + /// + /// Each entry describes an input port, a cardinality case to test, + /// and the test value to use. This enables automatic generation of + /// edge case tests. + /// + /// For example, a `FilterFiles` operation might provide: + /// - ("files", Empty, []) - test with empty input + /// - ("files", One, ["single.rs"]) - test with one file + /// - ("files", Many, ["a.rs", "b.rs", "c.rs"]) - test with multiple files + fn cardinality_inputs(&self) -> Vec { + vec![] // Default: no special cardinality tests + } + + /// Provide error cases this operation can produce. + /// + /// Each entry describes inputs that should cause an error, + /// along with the expected error message pattern. + fn error_cases(&self) -> Vec { + vec![] // Default: no documented error cases + } + + /// Check if this operation has any cardinality test inputs. + fn has_cardinality_tests(&self) -> bool { + !self.cardinality_inputs().is_empty() + } + + /// Check if this operation has any error cases. + fn has_error_tests(&self) -> bool { + !self.error_cases().is_empty() + } +} + +/// A cardinality test input for a specific port. +#[derive(Debug, Clone)] +pub struct CardinalityTestInput { + /// The port name to provide input for + pub port: String, + /// The cardinality case being tested + pub case: CardinalityCase, + /// The test value to use + pub value: Value, + /// Expected behavior when this input is used + pub expected: ExpectedBehavior, +} + +impl CardinalityTestInput { + /// Create a new cardinality test input that should succeed. + pub fn succeeds(port: impl Into, case: CardinalityCase, value: Value) -> Self { + Self { + port: port.into(), + case, + value, + expected: ExpectedBehavior::Succeeds, + } + } + + /// Create a new cardinality test input that should fail. + pub fn fails( + port: impl Into, + case: CardinalityCase, + value: Value, + error_pattern: impl Into, + ) -> Self { + Self { + port: port.into(), + case, + value, + expected: ExpectedBehavior::FailsWith(error_pattern.into()), + } + } +} + +/// Expected behavior for a test case. +#[derive(Debug, Clone)] +pub enum ExpectedBehavior { + /// The operation should succeed + Succeeds, + /// The operation should fail with an error containing this substring + FailsWith(String), +} + +impl ExpectedBehavior { + /// Check if this expectation matches a result. + pub fn matches(&self, result: &Result<(), String>) -> bool { + match (self, result) { + (ExpectedBehavior::Succeeds, Ok(())) => true, + (ExpectedBehavior::FailsWith(pattern), Err(msg)) => msg.contains(pattern), + _ => false, + } + } +} + +/// An error test case. +#[derive(Debug, Clone)] +pub struct ErrorTestCase { + /// A name for this test case + pub name: String, + /// The inputs to provide + pub inputs: HashMap, + /// The expected error message pattern + pub expected_error: String, +} + +impl ErrorTestCase { + /// Create a new error test case. + pub fn new( + name: impl Into, + inputs: HashMap, + expected_error: impl Into, + ) -> Self { + Self { + name: name.into(), + inputs, + expected_error: expected_error.into(), + } + } +} + +/// Helper macro for creating HashMap literals. +/// +/// # Example +/// +/// ```ignore +/// let outputs = mock_hashmap! { +/// "files" => Value::StrList(vec!["test.rs".into()]), +/// "count" => Value::Int(1) +/// }; +/// ``` +#[macro_export] +macro_rules! mock_hashmap { + ($($key:expr => $value:expr),* $(,)?) => {{ + let mut map = std::collections::HashMap::new(); + $( + map.insert($key.to_string(), $value); + )* + map + }}; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, Clone)] + struct TestOp; + + impl Mockable for TestOp { + fn mock_outputs(&self) -> HashMap { + mock_hashmap! { + "result" => Value::Str("mock".into()) + } + } + + fn cardinality_inputs(&self) -> Vec { + vec![ + CardinalityTestInput::succeeds( + "input", + CardinalityCase::Empty, + Value::StrList(vec![]), + ), + CardinalityTestInput::succeeds( + "input", + CardinalityCase::One, + Value::StrList(vec!["one".into()]), + ), + ] + } + + fn error_cases(&self) -> Vec { + vec![ErrorTestCase::new( + "missing_input", + HashMap::new(), + "missing input", + )] + } + } + + #[test] + fn test_mockable_outputs() { + let op = TestOp; + let outputs = op.mock_outputs(); + assert!(outputs.contains_key("result")); + } + + #[test] + fn test_mockable_cardinality_inputs() { + let op = TestOp; + let inputs = op.cardinality_inputs(); + assert_eq!(inputs.len(), 2); + assert_eq!(inputs[0].case, CardinalityCase::Empty); + assert_eq!(inputs[1].case, CardinalityCase::One); + } + + #[test] + fn test_mockable_error_cases() { + let op = TestOp; + let cases = op.error_cases(); + assert_eq!(cases.len(), 1); + assert_eq!(cases[0].name, "missing_input"); + } + + #[test] + fn test_expected_behavior_matches() { + assert!(ExpectedBehavior::Succeeds.matches(&Ok(()))); + assert!(!ExpectedBehavior::Succeeds.matches(&Err("error".into()))); + + assert!(ExpectedBehavior::FailsWith("missing".into()) + .matches(&Err("missing input".into()))); + assert!(!ExpectedBehavior::FailsWith("missing".into()) + .matches(&Ok(()))); + } +} diff --git a/crates/gunbc-testgen/Cargo.toml b/crates/gunbc-testgen/Cargo.toml new file mode 100644 index 00000000000..10f63e10928 --- /dev/null +++ b/crates/gunbc-testgen/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "gunbc-testgen" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Test generation for gunbc DAGs" + +[dependencies] +gunbc-ir = { workspace = true } diff --git a/crates/gunbc-testgen/src/analyze.rs b/crates/gunbc-testgen/src/analyze.rs new file mode 100644 index 00000000000..7914f39a4d6 --- /dev/null +++ b/crates/gunbc-testgen/src/analyze.rs @@ -0,0 +1,159 @@ +//! DAG analysis for test generation. + +use gunbc_ir::{detect_boundaries, BoundaryInfo, Cardinality, CardinalityCase, Dag, TypeId}; + +/// Analysis of a DAG for test generation. +#[derive(Debug)] +pub struct DagAnalysis { + /// Boundary information + pub boundaries: BoundaryInfo, + /// Edge type compatibility results + pub edge_types: Vec, + /// Port cardinality information + pub port_cardinalities: Vec, + /// Total number of nodes + pub node_count: usize, + /// Total number of edges + pub edge_count: usize, +} + +/// Information about an edge's types. +#[derive(Debug)] +pub struct EdgeTypeInfo { + pub from_node: String, + pub from_port: String, + pub to_node: String, + pub to_port: String, + pub from_type: TypeId, + pub to_type: TypeId, + pub compatible: bool, +} + +/// Information about a port's cardinality. +#[derive(Debug, Clone)] +pub struct PortCardinalityInfo { + pub node_id: String, + pub port_name: String, + pub is_input: bool, + pub type_id: TypeId, + pub cardinality: Cardinality, + /// Test cases that should be generated for this port + pub test_cases: Vec, +} + +impl PortCardinalityInfo { + /// Check if this port needs cardinality tests. + pub fn needs_tests(&self) -> bool { + self.test_cases.len() > 1 + } + + /// Check if this is a list port (ZeroOrMore or OneOrMore). + pub fn is_list(&self) -> bool { + self.cardinality.allows_many() + } +} + +/// Analyze a DAG for test generation. +pub fn analyze_dag(dag: &Dag) -> DagAnalysis { + let boundaries = detect_boundaries(dag); + let edge_types = analyze_edges(dag); + let port_cardinalities = analyze_port_cardinalities(dag); + + DagAnalysis { + boundaries, + edge_types, + port_cardinalities, + node_count: dag.nodes.len(), + edge_count: dag.edges.len(), + } +} + +/// Analyze port cardinalities in a DAG. +fn analyze_port_cardinalities(dag: &Dag) -> Vec { + let mut results = Vec::new(); + + for node in &dag.nodes { + // Analyze input ports + for port in &node.inputs { + results.push(PortCardinalityInfo { + node_id: node.id.0.clone(), + port_name: port.name.0.clone(), + is_input: true, + type_id: port.type_id.clone(), + cardinality: port.cardinality, + test_cases: port.cardinality.test_cases(), + }); + } + + // Analyze output ports + for port in &node.outputs { + results.push(PortCardinalityInfo { + node_id: node.id.0.clone(), + port_name: port.name.0.clone(), + is_input: false, + type_id: port.type_id.clone(), + cardinality: port.cardinality, + test_cases: port.cardinality.test_cases(), + }); + } + } + + results +} + +/// Analyze edge types in a DAG. +fn analyze_edges(dag: &Dag) -> Vec { + let mut results = Vec::new(); + + for edge in &dag.edges { + let from_node = dag.get_node(&edge.from_node); + let to_node = dag.get_node(&edge.to_node); + + if let (Some(from), Some(to)) = (from_node, to_node) { + let from_port = from.outputs.iter().find(|p| p.name == edge.from_port); + let to_port = to.inputs.iter().find(|p| p.name == edge.to_port); + + if let (Some(fp), Some(tp)) = (from_port, to_port) { + let compatible = types_compatible(&fp.type_id, &tp.type_id); + + results.push(EdgeTypeInfo { + from_node: edge.from_node.0.clone(), + from_port: edge.from_port.0.clone(), + to_node: edge.to_node.0.clone(), + to_port: edge.to_port.0.clone(), + from_type: fp.type_id.clone(), + to_type: tp.type_id.clone(), + compatible, + }); + } + } + } + + results +} + +/// Check if two types are compatible. +fn types_compatible(from: &TypeId, to: &TypeId) -> bool { + from.0 == to.0 || from.0 == "Any" || to.0 == "Any" +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::{build::*, Dag, Node}; + + #[test] + fn test_analyze_simple_dag() { + let mut dag: Dag<()> = Dag::new(); + dag.add_node(Node::opaque("A", vec![], vec![port("out", "String")], ())); + dag.add_node(Node::opaque("B", vec![port("in", "String")], vec![port("out", "String")], ())); + dag.add_edge(edge("A", "out", "B", "in")); + + let analysis = analyze_dag(&dag); + + assert_eq!(analysis.node_count, 2); + assert_eq!(analysis.edge_count, 1); + assert_eq!(analysis.boundaries.boundary_nodes.len(), 1); + assert!(analysis.edge_types[0].compatible); + } +} diff --git a/crates/gunbc-testgen/src/codegen.rs b/crates/gunbc-testgen/src/codegen.rs new file mode 100644 index 00000000000..925f49cf34d --- /dev/null +++ b/crates/gunbc-testgen/src/codegen.rs @@ -0,0 +1,269 @@ +//! Test code generation. + +use crate::analyze::{analyze_dag, DagAnalysis}; +use gunbc_ir::{CardinalityCase, Dag}; + +/// Configuration for test generation. +#[derive(Debug, Clone)] +pub struct TestConfig { + /// Generate boundary tests + pub boundary_tests: bool, + /// Generate composition tests + pub composition_tests: bool, + /// Generate cardinality tests for list ports + pub cardinality_tests: bool, + /// Test module visibility + pub visibility: String, +} + +impl Default for TestConfig { + fn default() -> Self { + Self { + boundary_tests: true, + composition_tests: true, + cardinality_tests: true, + visibility: "pub".to_string(), + } + } +} + +/// Test code generator. +pub struct TestGenerator<'a, T> { + dag: &'a Dag, + config: TestConfig, +} + +impl<'a, T> TestGenerator<'a, T> { + /// Create a new test generator for a DAG. + pub fn new(dag: &'a Dag) -> Self { + Self { + dag, + config: TestConfig::default(), + } + } + + /// Set the test configuration. + pub fn with_config(mut self, config: TestConfig) -> Self { + self.config = config; + self + } + + /// Generate the test module code. + pub fn generate_test_module(&self, module_name: &str, graph_builder_fn: &str) -> String { + let analysis = analyze_dag(self.dag); + let mut code = String::new(); + + // Module header + code.push_str(&format!( + "//! Generated tests for {} DAG.\n", + module_name + )); + code.push_str("//!\n"); + code.push_str("//! DO NOT EDIT - generated by gunbc-testgen.\n\n"); + + // Imports + code.push_str("use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode};\n"); + code.push_str("use gunbc_ir::{detect_boundaries, Cardinality, Value};\n"); + code.push_str("use gunbc_test::{assert_boundary_mockable, assert_types_compatible, default_mocks};\n\n"); + + // Boundary tests + if self.config.boundary_tests { + code.push_str(&self.generate_boundary_tests(&analysis, graph_builder_fn)); + } + + // Composition tests + if self.config.composition_tests { + code.push_str(&self.generate_composition_tests(&analysis, graph_builder_fn)); + } + + // Cardinality tests + if self.config.cardinality_tests { + code.push_str(&self.generate_cardinality_tests(&analysis, graph_builder_fn)); + } + + code + } + + /// Generate boundary tests. + fn generate_boundary_tests(&self, analysis: &DagAnalysis, graph_builder_fn: &str) -> String { + let mut code = String::new(); + + // Test that the graph is boundary-mockable + code.push_str("/// Test that all boundaries can be mocked.\n"); + code.push_str("#[test]\n"); + code.push_str("fn test_boundaries_mockable() {\n"); + code.push_str(&format!(" let dag = {};\n", graph_builder_fn)); + code.push_str(" let result = assert_boundary_mockable(&dag, default_mocks());\n"); + code.push_str(" assert!(result.is_ok(), \"Boundaries should be mockable: {:?}\", result.error);\n"); + code.push_str("}\n\n"); + + // Individual boundary node tests + for boundary_node in &analysis.boundaries.boundary_nodes { + let test_name = format!("test_boundary_{}_mockable", boundary_node.0.replace('-', "_")); + let node_name = &boundary_node.0; + + code.push_str(&format!("/// Test that {} boundary can be mocked.\n", node_name)); + code.push_str("#[test]\n"); + code.push_str(&format!("fn {}() {{\n", test_name)); + code.push_str(&format!(" let dag = {};\n", graph_builder_fn)); + code.push_str(" let boundaries = detect_boundaries(&dag);\n"); + code.push_str(&format!( + " assert!(boundaries.is_boundary_node(&\"{}\".into()), \"{} should be a boundary\");\n", + node_name, node_name + )); + code.push_str(" \n"); + code.push_str(" let mut mocks = BoundaryMocks::new();\n"); + + // Add mocks for all boundary ports on this node + for (node_id, port_name) in &analysis.boundaries.boundary_ports { + if node_id == boundary_node { + code.push_str(&format!( + " mocks.set_value(\"{}\", \"{}\", Value::Str(\"\".to_string()));\n", + node_id.0, port_name.0 + )); + } + } + + code.push_str(" \n"); + code.push_str(" let log = execute_with_mode(&dag, ExecutionMode::DryRun(mocks)).unwrap();\n"); + code.push_str(&format!( + " let entry = log.get(\"{}\").expect(\"node should be in log\");\n", + node_name + )); + code.push_str(" assert!(entry.was_intercepted, \"boundary should be intercepted in dry-run\");\n"); + code.push_str("}\n\n"); + } + + code + } + + /// Generate composition tests. + fn generate_composition_tests(&self, analysis: &DagAnalysis, graph_builder_fn: &str) -> String { + let mut code = String::new(); + + // Test that all edges are compatible + code.push_str("/// Test that all edge types are compatible.\n"); + code.push_str("#[test]\n"); + code.push_str("fn test_all_edges_compatible() {\n"); + code.push_str(&format!(" let dag = {};\n", graph_builder_fn)); + code.push_str(" let results = assert_types_compatible(&dag);\n"); + code.push_str(" for result in &results {\n"); + code.push_str(" assert!(result.is_compatible(), \"Edge {} should be compatible\", result.edge);\n"); + code.push_str(" }\n"); + code.push_str("}\n\n"); + + // Individual edge tests + for edge_info in &analysis.edge_types { + let test_name = format!( + "test_edge_{}_{}_to_{}_{}", + edge_info.from_node.replace('-', "_"), + edge_info.from_port.replace('-', "_"), + edge_info.to_node.replace('-', "_"), + edge_info.to_port.replace('-', "_") + ); + + code.push_str(&format!( + "/// Test edge {}.{} -> {}.{} type compatibility.\n", + edge_info.from_node, edge_info.from_port, + edge_info.to_node, edge_info.to_port + )); + code.push_str("#[test]\n"); + code.push_str(&format!("fn {}() {{\n", test_name)); + code.push_str(&format!(" // {} -> {}\n", edge_info.from_type, edge_info.to_type)); + code.push_str(&format!( + " assert!({}, \"Types {} and {} should be compatible\");\n", + edge_info.compatible, + edge_info.from_type, edge_info.to_type + )); + code.push_str("}\n\n"); + } + + code + } + + /// Generate cardinality tests for list ports. + fn generate_cardinality_tests(&self, analysis: &DagAnalysis, _graph_builder_fn: &str) -> String { + let mut code = String::new(); + + // Find ports that need cardinality tests (lists with multiple test cases) + let list_ports: Vec<_> = analysis + .port_cardinalities + .iter() + .filter(|p| p.needs_tests() && p.is_input) + .collect(); + + if list_ports.is_empty() { + return code; + } + + code.push_str("// ============================================================================\n"); + code.push_str("// Cardinality Tests\n"); + code.push_str("// These tests verify behavior at cardinality boundaries (empty, one, many).\n"); + code.push_str("// Actual test values should come from Mockable::cardinality_inputs().\n"); + code.push_str("// ============================================================================\n\n"); + + // Generate a summary test that lists all ports with their cardinalities + code.push_str("/// Verify cardinality annotations on ports.\n"); + code.push_str("#[test]\n"); + code.push_str("fn test_port_cardinalities() {\n"); + code.push_str(" // This test documents the expected cardinalities for list ports.\n"); + code.push_str(" // Use Mockable::cardinality_inputs() on operations to get test values.\n"); + + for port_info in &list_ports { + let cardinality_str = match port_info.cardinality { + gunbc_ir::Cardinality::Zero => "Zero", + gunbc_ir::Cardinality::One => "One", + gunbc_ir::Cardinality::ZeroOrOne => "ZeroOrOne", + gunbc_ir::Cardinality::ZeroOrMore => "ZeroOrMore", + gunbc_ir::Cardinality::OneOrMore => "OneOrMore", + }; + + let cases: Vec<&str> = port_info + .test_cases + .iter() + .map(|c| match c { + CardinalityCase::Empty => "Empty", + CardinalityCase::One => "One", + CardinalityCase::Many => "Many", + }) + .collect(); + + code.push_str(&format!( + " // {}.{}: {} -> test cases: [{}]\n", + port_info.node_id, + port_info.port_name, + cardinality_str, + cases.join(", ") + )); + } + + code.push_str(" \n"); + code.push_str(" // To run actual cardinality tests, implement Mockable for your operations\n"); + code.push_str(" // and call cardinality_inputs() to get the test values.\n"); + code.push_str("}\n\n"); + + code + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::{build::*, Dag, Node}; + + #[test] + fn test_generate_test_module() { + let mut dag: Dag<()> = Dag::new(); + dag.add_node(Node::opaque("source", vec![], vec![port("out", "String")], ())); + dag.add_node(Node::opaque("sink", vec![port("in", "String")], vec![port("result", "String")], ())); + dag.add_edge(edge("source", "out", "sink", "in")); + + let generator = TestGenerator::new(&dag); + let code = generator.generate_test_module("example", "build_example_graph()"); + + assert!(code.contains("test_boundaries_mockable")); + assert!(code.contains("test_boundary_sink_mockable")); + assert!(code.contains("test_all_edges_compatible")); + assert!(code.contains("test_edge_source_out_to_sink_in")); + } +} diff --git a/crates/gunbc-testgen/src/lib.rs b/crates/gunbc-testgen/src/lib.rs new file mode 100644 index 00000000000..37d16764a40 --- /dev/null +++ b/crates/gunbc-testgen/src/lib.rs @@ -0,0 +1,22 @@ +//! gunbc-testgen: Test generation for gunbc DAGs. +//! +//! This crate generates test code from DAG structures: +//! - Boundary tests: verify world-write boundaries can be mocked +//! - Composition tests: verify edge types are compatible +//! +//! # Usage +//! +//! ```ignore +//! use gunbc_testgen::{TestGenerator, TestConfig}; +//! +//! let generator = TestGenerator::new(&dag) +//! .with_config(TestConfig::default()); +//! +//! let code = generator.generate_test_module("gist_tests"); +//! ``` + +pub mod codegen; +pub mod analyze; + +pub use analyze::{DagAnalysis, analyze_dag}; +pub use codegen::{TestGenerator, TestConfig}; diff --git a/crates/gunbc-transport/Cargo.toml b/crates/gunbc-transport/Cargo.toml new file mode 100644 index 00000000000..d88a93e1177 --- /dev/null +++ b/crates/gunbc-transport/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "gunbc-transport" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Transport layer executors for gunbc I/O operations" + +[dependencies] +gunbc-ir = { workspace = true } +gunbc-exec = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/gunbc-transport/src/executor.rs b/crates/gunbc-transport/src/executor.rs new file mode 100644 index 00000000000..acd7a94bf54 --- /dev/null +++ b/crates/gunbc-transport/src/executor.rs @@ -0,0 +1,446 @@ +//! Transport request executors. + +use gunbc_ir::transport::{ + FileOp, FileRequest, FileResponse, HttpRequest, HttpResponse, RestRequest, + RestResponse, ShellRequest, ShellResponse, TcpRequest, TcpResponse, TransportRequest, + TransportResponse, +}; +use std::collections::HashMap; +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::process::{Command, Stdio}; +use std::time::Duration; + +/// Transport execution error. +#[derive(Debug)] +pub struct TransportError { + pub message: String, +} + +impl TransportError { + pub fn new(msg: impl Into) -> Self { + Self { + message: msg.into(), + } + } +} + +impl std::fmt::Display for TransportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for TransportError {} + +/// Execute a transport request. +pub fn execute_transport(request: &TransportRequest) -> Result { + match request { + TransportRequest::Rest(r) => execute_rest(r).map(TransportResponse::Rest), + TransportRequest::Http(r) => execute_http(r).map(TransportResponse::Http), + TransportRequest::File(r) => execute_file(r).map(TransportResponse::File), + TransportRequest::Tcp(r) => execute_tcp(r).map(TransportResponse::Tcp), + TransportRequest::Shell(r) => execute_shell(r).map(TransportResponse::Shell), + } +} + +/// Execute a REST request. +/// +/// Note: This is a simplified implementation. In production, you'd use +/// a proper HTTP client like reqwest. +fn execute_rest(request: &RestRequest) -> Result { + // For now, convert to HTTP and use that + let mut http_req = HttpRequest::post(&request.url); + http_req.method = request.method; + + // Add headers + for (k, v) in &request.headers { + http_req.headers.insert(k.clone(), v.clone()); + } + + // Add JSON body + if let Some(ref body) = request.body { + http_req.body = Some(serde_json::to_string(body).map_err(|e| { + TransportError::new(format!("failed to serialize body: {}", e)) + })?); + http_req + .headers + .insert("Content-Type".to_string(), "application/json".to_string()); + } + + // Handle auth + if let Some(ref auth) = request.auth { + match auth { + gunbc_ir::transport::AuthMethod::Bearer(token) => { + http_req + .headers + .insert("Authorization".to_string(), format!("Bearer {}", token)); + } + gunbc_ir::transport::AuthMethod::EnvVar(var) => { + if let Ok(token) = std::env::var(var) { + http_req + .headers + .insert("Authorization".to_string(), format!("Bearer {}", token)); + } + } + gunbc_ir::transport::AuthMethod::Basic { username, password } => { + let creds = base64_encode(&format!("{}:{}", username, password)); + http_req + .headers + .insert("Authorization".to_string(), format!("Basic {}", creds)); + } + gunbc_ir::transport::AuthMethod::ApiKey { header, key } => { + http_req.headers.insert(header.clone(), key.clone()); + } + gunbc_ir::transport::AuthMethod::None => {} + } + } + + http_req.timeout_ms = request.timeout_ms; + + let http_resp = execute_http(&http_req)?; + + // Parse JSON response + let body: serde_json::Value = serde_json::from_str(&http_resp.body).unwrap_or_else(|_| { + serde_json::json!({ "raw": http_resp.body }) + }); + + Ok(RestResponse { + status: http_resp.status, + headers: http_resp.headers, + body, + }) +} + +/// Simple base64 encoding for basic auth. +fn base64_encode(input: &str) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let bytes = input.as_bytes(); + let mut result = String::new(); + + for chunk in bytes.chunks(3) { + let b0 = chunk[0] as usize; + let b1 = chunk.get(1).copied().unwrap_or(0) as usize; + let b2 = chunk.get(2).copied().unwrap_or(0) as usize; + + result.push(ALPHABET[b0 >> 2] as char); + result.push(ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)] as char); + + if chunk.len() > 1 { + result.push(ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)] as char); + } else { + result.push('='); + } + + if chunk.len() > 2 { + result.push(ALPHABET[b2 & 0x3f] as char); + } else { + result.push('='); + } + } + + result +} + +/// Execute a raw HTTP request. +/// +/// This is a simplified implementation using std::net. +/// In production, use reqwest or similar. +fn execute_http(request: &HttpRequest) -> Result { + // Parse URL to extract host and path + let url = &request.url; + + // For now, return a mock response for non-local URLs + // A real implementation would use reqwest or similar + if !url.starts_with("http://localhost") && !url.starts_with("http://127.0.0.1") { + return Err(TransportError::new( + "HTTP transport not fully implemented - use Shell transport with curl for now" + )); + } + + // Simple localhost handling + let parts: Vec<&str> = url.trim_start_matches("http://").splitn(2, '/').collect(); + let host_port = parts[0]; + let path = format!("/{}", parts.get(1).unwrap_or(&"")); + + let mut stream = TcpStream::connect(host_port) + .map_err(|e| TransportError::new(format!("connection failed: {}", e)))?; + + if let Some(timeout) = request.timeout_ms { + stream.set_read_timeout(Some(Duration::from_millis(timeout))).ok(); + stream.set_write_timeout(Some(Duration::from_millis(timeout))).ok(); + } + + // Build request + let mut req_str = format!("{} {} HTTP/1.1\r\n", request.method, path); + req_str.push_str(&format!("Host: {}\r\n", host_port)); + + for (key, value) in &request.headers { + req_str.push_str(&format!("{}: {}\r\n", key, value)); + } + + if let Some(ref body) = request.body { + req_str.push_str(&format!("Content-Length: {}\r\n", body.len())); + } + + req_str.push_str("\r\n"); + + if let Some(ref body) = request.body { + req_str.push_str(body); + } + + stream.write_all(req_str.as_bytes()) + .map_err(|e| TransportError::new(format!("write failed: {}", e)))?; + + // Read response + let mut response = String::new(); + stream.read_to_string(&mut response) + .map_err(|e| TransportError::new(format!("read failed: {}", e)))?; + + // Parse response (very basic) + let mut lines = response.lines(); + let status_line = lines.next().unwrap_or("HTTP/1.1 500 Error"); + let status: u16 = status_line + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(500); + + let mut headers = HashMap::new(); + let mut body = String::new(); + let mut in_body = false; + + for line in lines { + if in_body { + body.push_str(line); + body.push('\n'); + } else if line.is_empty() { + in_body = true; + } else if let Some((key, value)) = line.split_once(": ") { + headers.insert(key.to_string(), value.to_string()); + } + } + + Ok(HttpResponse { + status, + headers, + body, + }) +} + +/// Execute a file operation. +fn execute_file(request: &FileRequest) -> Result { + match request.operation { + FileOp::Read => { + match fs::read_to_string(&request.path) { + Ok(content) => Ok(FileResponse::read_ok(&request.path, content)), + Err(e) => Ok(FileResponse::error(&request.path, FileOp::Read, e.to_string())), + } + } + FileOp::Write => { + if request.create_parents { + if let Some(parent) = std::path::Path::new(&request.path).parent() { + fs::create_dir_all(parent).ok(); + } + } + + let content = request.content.as_deref().unwrap_or(""); + match fs::write(&request.path, content) { + Ok(()) => Ok(FileResponse::written(&request.path)), + Err(e) => Ok(FileResponse::error(&request.path, FileOp::Write, e.to_string())), + } + } + FileOp::Append => { + use std::fs::OpenOptions; + + if request.create_parents { + if let Some(parent) = std::path::Path::new(&request.path).parent() { + fs::create_dir_all(parent).ok(); + } + } + + let content = request.content.as_deref().unwrap_or(""); + match OpenOptions::new() + .append(true) + .create(true) + .open(&request.path) + { + Ok(mut file) => { + match file.write_all(content.as_bytes()) { + Ok(()) => Ok(FileResponse { + path: request.path.clone(), + operation: FileOp::Append, + success: true, + content: None, + exists: None, + error: None, + }), + Err(e) => Ok(FileResponse::error(&request.path, FileOp::Append, e.to_string())), + } + } + Err(e) => Ok(FileResponse::error(&request.path, FileOp::Append, e.to_string())), + } + } + FileOp::Delete => { + match fs::remove_file(&request.path) { + Ok(()) => Ok(FileResponse { + path: request.path.clone(), + operation: FileOp::Delete, + success: true, + content: None, + exists: None, + error: None, + }), + Err(e) => Ok(FileResponse::error(&request.path, FileOp::Delete, e.to_string())), + } + } + FileOp::Exists => { + let exists = std::path::Path::new(&request.path).exists(); + Ok(FileResponse::exists_result(&request.path, exists)) + } + FileOp::CreateDir => { + match fs::create_dir_all(&request.path) { + Ok(()) => Ok(FileResponse { + path: request.path.clone(), + operation: FileOp::CreateDir, + success: true, + content: None, + exists: None, + error: None, + }), + Err(e) => Ok(FileResponse::error(&request.path, FileOp::CreateDir, e.to_string())), + } + } + } +} + +/// Execute a TCP request. +fn execute_tcp(request: &TcpRequest) -> Result { + let addr = format!("{}:{}", request.host, request.port); + + let mut stream = TcpStream::connect(&addr) + .map_err(|e| TransportError::new(format!("connection failed: {}", e)))?; + + if let Some(timeout) = request.connect_timeout_ms { + stream.set_read_timeout(Some(Duration::from_millis(timeout))).ok(); + } + if let Some(timeout) = request.read_timeout_ms { + stream.set_write_timeout(Some(Duration::from_millis(timeout))).ok(); + } + + let mut bytes_sent = 0; + if let Some(ref data) = request.data { + stream.write_all(data.as_bytes()) + .map_err(|e| TransportError::new(format!("write failed: {}", e)))?; + bytes_sent = data.len(); + } + + let mut response = String::new(); + stream.read_to_string(&mut response).ok(); // May timeout, that's ok + let bytes_received = response.len(); + + Ok(TcpResponse::ok( + if response.is_empty() { None } else { Some(response) }, + bytes_sent, + bytes_received, + )) +} + +/// Execute a shell command. +fn execute_shell(request: &ShellRequest) -> Result { + let mut cmd = Command::new(&request.command); + cmd.args(&request.args); + + if let Some(ref cwd) = request.cwd { + cmd.current_dir(cwd); + } + + for (key, value) in &request.env { + cmd.env(key, value); + } + + // Handle stdin + if request.stdin.is_some() { + cmd.stdin(Stdio::piped()); + } + + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd.spawn() + .map_err(|e| TransportError::new(format!("failed to spawn: {}", e)))?; + + // Write stdin if provided + if let Some(ref stdin_data) = request.stdin { + if let Some(ref mut stdin) = child.stdin { + stdin.write_all(stdin_data.as_bytes()).ok(); + } + } + + let output = child.wait_with_output() + .map_err(|e| TransportError::new(format!("failed to wait: {}", e)))?; + + Ok(ShellResponse { + exit_code: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_file_read() { + let request = FileRequest::read("Cargo.toml"); + let response = execute_file(&request).unwrap(); + + assert!(response.success); + assert!(response.content.is_some()); + assert!(response.content.unwrap().contains("[package]")); + } + + #[test] + fn test_file_exists() { + let request = FileRequest::exists("Cargo.toml"); + let response = execute_file(&request).unwrap(); + + assert!(response.success); + assert_eq!(response.exists, Some(true)); + } + + #[test] + fn test_file_not_exists() { + let request = FileRequest::exists("nonexistent_file_12345.txt"); + let response = execute_file(&request).unwrap(); + + assert!(response.success); + assert_eq!(response.exists, Some(false)); + } + + #[test] + fn test_shell_echo() { + let request = ShellRequest::new("echo").arg("hello"); + let response = execute_shell(&request).unwrap(); + + assert_eq!(response.exit_code, 0); + assert!(response.stdout.contains("hello")); + } + + #[test] + fn test_shell_with_stdin() { + let request = ShellRequest::new("cat").stdin("test input"); + let response = execute_shell(&request).unwrap(); + + assert_eq!(response.exit_code, 0); + assert_eq!(response.stdout.trim(), "test input"); + } + + #[test] + fn test_base64_encode() { + assert_eq!(base64_encode("hello"), "aGVsbG8="); + assert_eq!(base64_encode("user:pass"), "dXNlcjpwYXNz"); + } +} diff --git a/crates/gunbc-transport/src/lib.rs b/crates/gunbc-transport/src/lib.rs new file mode 100644 index 00000000000..4713be18416 --- /dev/null +++ b/crates/gunbc-transport/src/lib.rs @@ -0,0 +1,21 @@ +//! gunbc-transport: Transport layer executors. +//! +//! This crate provides the `TransportOp` executor that handles all I/O operations. +//! It is the unified boundary for all world interactions: +//! +//! - REST/HTTP API calls +//! - File system operations +//! - TCP connections +//! - Shell command execution +//! +//! # Design +//! +//! Business logic prepares `TransportRequest` values, which flow through edges +//! to `TransportOp::Execute` nodes. These nodes are the only boundaries in the DAG, +//! making dry-run interception uniform across all I/O types. + +pub mod executor; +pub mod ops; + +pub use executor::{execute_transport, TransportError}; +pub use ops::TransportOp; diff --git a/crates/gunbc-transport/src/ops.rs b/crates/gunbc-transport/src/ops.rs new file mode 100644 index 00000000000..83f09e73ab5 --- /dev/null +++ b/crates/gunbc-transport/src/ops.rs @@ -0,0 +1,84 @@ +//! Transport operation type. + +use crate::executor::execute_transport; +use gunbc_exec::{ExecError, Executable}; +use gunbc_ir::Value; +use std::collections::HashMap; + +/// Transport operations. +/// +/// This is the universal executor for all I/O operations. +/// It takes a `TransportRequest` and returns a `TransportResponse`. +#[derive(Debug, Clone, Copy)] +pub enum TransportOp { + /// Execute a transport request (the only I/O boundary) + Execute, +} + +impl Executable for TransportOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + match self { + TransportOp::Execute => execute_op(inputs), + } + } +} + +/// Execute a transport request. +fn execute_op(inputs: HashMap) -> Result, ExecError> { + let request = match inputs.get("request") { + Some(Value::Request(r)) => r.clone(), + Some(other) => { + return Err(ExecError::new(format!( + "expected Request value, got: {}", + other + ))) + } + None => return Err(ExecError::new("missing 'request' input")), + }; + + let response = execute_transport(&request) + .map_err(|e| ExecError::new(format!("transport error: {}", e)))?; + + let mut out = HashMap::new(); + out.insert("response".to_string(), Value::Response(response)); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::transport::{FileRequest, TransportRequest}; + + #[test] + fn test_execute_requires_request_input() { + let result = execute_op(HashMap::new()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("missing")); + } + + #[test] + fn test_execute_wrong_value_type() { + let mut inputs = HashMap::new(); + inputs.insert("request".to_string(), Value::Str("not a request".into())); + + let result = execute_op(inputs); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("expected Request")); + } + + #[test] + fn test_execute_file_exists() { + let mut inputs = HashMap::new(); + inputs.insert( + "request".to_string(), + Value::Request(TransportRequest::File(FileRequest::exists("Cargo.toml"))), + ); + + let result = execute_op(inputs).unwrap(); + + match result.get("response") { + Some(Value::Response(_)) => {} + _ => panic!("expected Response"), + } + } +} diff --git a/crates/gunbc-validate/Cargo.toml b/crates/gunbc-validate/Cargo.toml deleted file mode 100644 index 4202a24c95a..00000000000 --- a/crates/gunbc-validate/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "gunbc-validate" -version = "0.1.0" -edition = "2021" - -[dependencies] -gunbc-ir = { path = "../gunbc-ir" } diff --git a/crates/gunbc-validate/src/lib.rs b/crates/gunbc-validate/src/lib.rs deleted file mode 100644 index a3f97a23bb1..00000000000 --- a/crates/gunbc-validate/src/lib.rs +++ /dev/null @@ -1,504 +0,0 @@ -use std::collections::{HashMap, HashSet, VecDeque}; -use std::fmt; - -use gunbc_ir::{Dag, Node, NodeBody}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ValidationError { - DuplicateNodeId(String), - CycleDetected, - TypeMismatch { - edge_from: String, - edge_to: String, - from_type: String, - to_type: String, - }, - UnsatisfiedInput { - node: String, - port: String, - }, - MissingPatternDecision { - tool: String, - }, - UnknownNode { - edge_desc: String, - node_id: String, - }, - UnknownPort { - edge_desc: String, - node_id: String, - port_name: String, - }, - DuplicateInputEdge { - node: String, - port: String, - }, - ExportNodeNotFound { - wrapper_node: String, - export_node: String, - }, - ExportNodeMissingPort { - wrapper_node: String, - export_node: String, - port: String, - }, -} - -impl fmt::Display for ValidationError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::DuplicateNodeId(id) => write!(f, "duplicate node id: {id}"), - Self::CycleDetected => write!(f, "cycle detected in DAG"), - Self::TypeMismatch { edge_from, edge_to, from_type, to_type } => { - write!(f, "type mismatch on edge {edge_from} -> {edge_to}: {from_type} != {to_type}") - } - Self::UnsatisfiedInput { node, port } => { - write!(f, "unsatisfied input port '{port}' on node '{node}'") - } - Self::MissingPatternDecision { tool } => { - write!(f, "tool '{tool}' has no pattern decision in DAG metadata") - } - Self::UnknownNode { edge_desc, node_id } => { - write!(f, "edge {edge_desc} references unknown node '{node_id}'") - } - Self::UnknownPort { edge_desc, node_id, port_name } => { - write!(f, "edge {edge_desc} references unknown port '{port_name}' on node '{node_id}'") - } - Self::DuplicateInputEdge { node, port } => { - write!(f, "multiple edges target input port '{port}' on node '{node}'") - } - Self::ExportNodeNotFound { wrapper_node, export_node } => { - write!(f, "subdag in node '{wrapper_node}' references nonexistent export_node '{export_node}'") - } - Self::ExportNodeMissingPort { wrapper_node, export_node, port } => { - write!(f, "export_node '{export_node}' in subdag of '{wrapper_node}' is missing output port '{port}'") - } - } - } -} - -/// Validate a DAG, returning all errors found. -pub fn validate(dag: &Dag) -> Result<(), Vec> { - let mut errors = Vec::new(); - - check_duplicate_ids(dag, &mut errors); - check_acyclic(dag, &mut errors); - check_type_agreement(dag, &mut errors); - check_port_saturation(dag, &mut errors); - check_unique_input_edges(dag, &mut errors); - check_pattern_decisions(dag, &mut errors); - check_export_nodes(dag, &mut errors); - - if errors.is_empty() { - Ok(()) - } else { - Err(errors) - } -} - -fn check_duplicate_ids(dag: &Dag, errors: &mut Vec) { - let mut seen = HashSet::new(); - for node in &dag.nodes { - if !seen.insert(&node.id.0) { - errors.push(ValidationError::DuplicateNodeId(node.id.0.clone())); - } - } -} - -fn check_acyclic(dag: &Dag, errors: &mut Vec) { - let node_ids: Vec<&str> = dag.nodes.iter().map(|n| n.id.0.as_str()).collect(); - let mut in_degree: HashMap<&str, usize> = node_ids.iter().map(|id| (*id, 0usize)).collect(); - let mut adj: HashMap<&str, Vec<&str>> = node_ids.iter().map(|id| (*id, Vec::new())).collect(); - - for edge in &dag.edges { - if let Some(deg) = in_degree.get_mut(edge.to_node.0.as_str()) { - *deg += 1; - } - if let Some(neighbors) = adj.get_mut(edge.from_node.0.as_str()) { - neighbors.push(&edge.to_node.0); - } - } - - let mut queue: VecDeque<&str> = in_degree - .iter() - .filter(|(_, °)| deg == 0) - .map(|(&id, _)| id) - .collect(); - - let mut visited = 0usize; - while let Some(id) = queue.pop_front() { - visited += 1; - if let Some(neighbors) = adj.get(id) { - for &neighbor in neighbors { - if let Some(deg) = in_degree.get_mut(neighbor) { - *deg -= 1; - if *deg == 0 { - queue.push_back(neighbor); - } - } - } - } - } - - if visited != node_ids.len() { - errors.push(ValidationError::CycleDetected); - } -} - -fn check_type_agreement(dag: &Dag, errors: &mut Vec) { - let node_map: HashMap<&str, &Node> = dag.nodes.iter().map(|n| (n.id.0.as_str(), n)).collect(); - - for edge in &dag.edges { - let edge_desc = format!("{}.{} -> {}.{}", edge.from_node.0, edge.from_port.0, edge.to_node.0, edge.to_port.0); - - let from_node = match node_map.get(edge.from_node.0.as_str()) { - Some(n) => n, - None => { - errors.push(ValidationError::UnknownNode { - edge_desc, - node_id: edge.from_node.0.clone(), - }); - continue; - } - }; - let to_node = match node_map.get(edge.to_node.0.as_str()) { - Some(n) => n, - None => { - errors.push(ValidationError::UnknownNode { - edge_desc, - node_id: edge.to_node.0.clone(), - }); - continue; - } - }; - - let from_type = match from_node.outputs.iter().find(|p| p.name == edge.from_port) { - Some(p) => &p.type_id, - None => { - errors.push(ValidationError::UnknownPort { - edge_desc, - node_id: edge.from_node.0.clone(), - port_name: edge.from_port.0.clone(), - }); - continue; - } - }; - let to_type = match to_node.inputs.iter().find(|p| p.name == edge.to_port) { - Some(p) => &p.type_id, - None => { - errors.push(ValidationError::UnknownPort { - edge_desc, - node_id: edge.to_node.0.clone(), - port_name: edge.to_port.0.clone(), - }); - continue; - } - }; - - if from_type != to_type { - errors.push(ValidationError::TypeMismatch { - edge_from: format!("{}.{}", edge.from_node.0, edge.from_port.0), - edge_to: format!("{}.{}", edge.to_node.0, edge.to_port.0), - from_type: from_type.0.clone(), - to_type: to_type.0.clone(), - }); - } - } -} - -fn check_port_saturation(dag: &Dag, errors: &mut Vec) { - let mut satisfied: HashSet<(String, String)> = HashSet::new(); - for edge in &dag.edges { - satisfied.insert((edge.to_node.0.clone(), edge.to_port.0.clone())); - } - - for node in &dag.nodes { - for input in &node.inputs { - if !satisfied.contains(&(node.id.0.clone(), input.name.0.clone())) { - errors.push(ValidationError::UnsatisfiedInput { - node: node.id.0.clone(), - port: input.name.0.clone(), - }); - } - } - } -} - -fn check_unique_input_edges(dag: &Dag, errors: &mut Vec) { - let mut seen: HashSet<(String, String)> = HashSet::new(); - for edge in &dag.edges { - let key = (edge.to_node.0.clone(), edge.to_port.0.clone()); - if !seen.insert(key) { - errors.push(ValidationError::DuplicateInputEdge { - node: edge.to_node.0.clone(), - port: edge.to_port.0.clone(), - }); - } - } -} - -fn check_pattern_decisions(dag: &Dag, errors: &mut Vec) { - // Collect all unique tool IDs from nodes - let tools_in_nodes: HashSet<&str> = dag.nodes.iter() - .map(|n| n.metadata.tool.0.as_str()) - .collect(); - - // Collect tool IDs that have pattern decisions - let tools_with_decisions: HashSet<&str> = dag.metadata.pattern_decisions.iter() - .map(|entry| entry.tool.0.as_str()) - .collect(); - - for tool in &tools_in_nodes { - if !tools_with_decisions.contains(tool) { - errors.push(ValidationError::MissingPatternDecision { - tool: tool.to_string(), - }); - } - } -} - -fn check_export_nodes(dag: &Dag, errors: &mut Vec) { - for node in &dag.nodes { - if let NodeBody::SubDag(ref sub) = node.body { - if let Some(ref export_id) = sub.metadata.export_node { - let export_node = sub.nodes.iter().find(|n| n.id == *export_id); - match export_node { - None => { - errors.push(ValidationError::ExportNodeNotFound { - wrapper_node: node.id.0.clone(), - export_node: export_id.0.clone(), - }); - } - Some(en) => { - for output in &node.outputs { - let has_port = en.outputs.iter().any(|p| { - p.name == output.name && p.type_id == output.type_id - }); - if !has_port { - errors.push(ValidationError::ExportNodeMissingPort { - wrapper_node: node.id.0.clone(), - export_node: export_id.0.clone(), - port: output.name.0.clone(), - }); - } - } - } - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use gunbc_ir::*; - use gunbc_ir::metadata::NodeMetadata; - use gunbc_ir::types::{BehaviorKind, PatternDecision, ToolId}; - - fn simple_port(name: &str, ty: &str) -> Port { - Port { - name: PortName(name.into()), - type_id: TypeId(ty.into()), - guard: None, - } - } - - fn simple_node(id: &str, tool: &str, inputs: Vec, outputs: Vec) -> Node { - Node { - id: NodeId(id.into()), - inputs, - outputs, - metadata: NodeMetadata { - tool: ToolId(tool.into()), - behavior: BehaviorKind::Pure, - }, - body: NodeBody::Opaque(id.into()), - } - } - - fn decisions_for(tools: &[&str]) -> DagMetadata { - DagMetadata { - pattern_decisions: tools.iter().map(|t| PatternDecisionEntry { - tool: ToolId(t.to_string()), - pattern: "upsert".into(), - decision: PatternDecision::Instantiated, - }).collect(), - export_node: None, - } - } - - #[test] - fn valid_dag_passes() { - let dag = Dag { - nodes: vec![ - simple_node("a", "t", vec![], vec![simple_port("out", "String")]), - simple_node("b", "t", vec![simple_port("in", "String")], vec![]), - ], - edges: vec![Edge { - from_node: NodeId("a".into()), - from_port: PortName("out".into()), - to_node: NodeId("b".into()), - to_port: PortName("in".into()), - }], - metadata: decisions_for(&["t"]), - }; - assert!(validate(&dag).is_ok()); - } - - #[test] - fn cycle_detected() { - let dag = Dag { - nodes: vec![ - simple_node("a", "t", vec![simple_port("in", "S")], vec![simple_port("out", "S")]), - simple_node("b", "t", vec![simple_port("in", "S")], vec![simple_port("out", "S")]), - ], - edges: vec![ - Edge { from_node: NodeId("a".into()), from_port: PortName("out".into()), to_node: NodeId("b".into()), to_port: PortName("in".into()) }, - Edge { from_node: NodeId("b".into()), from_port: PortName("out".into()), to_node: NodeId("a".into()), to_port: PortName("in".into()) }, - ], - metadata: decisions_for(&["t"]), - }; - let errs = validate(&dag).unwrap_err(); - assert!(errs.iter().any(|e| matches!(e, ValidationError::CycleDetected))); - } - - #[test] - fn type_mismatch_rejected() { - let dag = Dag { - nodes: vec![ - simple_node("a", "t", vec![], vec![simple_port("out", "Int")]), - simple_node("b", "t", vec![simple_port("in", "String")], vec![]), - ], - edges: vec![Edge { - from_node: NodeId("a".into()), - from_port: PortName("out".into()), - to_node: NodeId("b".into()), - to_port: PortName("in".into()), - }], - metadata: decisions_for(&["t"]), - }; - let errs = validate(&dag).unwrap_err(); - assert!(errs.iter().any(|e| matches!(e, ValidationError::TypeMismatch { .. }))); - } - - #[test] - fn unsatisfied_port_rejected() { - let dag = Dag { - nodes: vec![ - simple_node("a", "t", vec![simple_port("needed", "S")], vec![]), - ], - edges: vec![], - metadata: decisions_for(&["t"]), - }; - let errs = validate(&dag).unwrap_err(); - assert!(errs.iter().any(|e| matches!(e, ValidationError::UnsatisfiedInput { .. }))); - } - - fn subdag_node(id: &str, tool: &str, inputs: Vec, outputs: Vec, sub: Dag) -> Node { - Node { - id: NodeId(id.into()), - inputs, - outputs, - metadata: NodeMetadata { - tool: ToolId(tool.into()), - behavior: BehaviorKind::Pure, - }, - body: NodeBody::SubDag(sub), - } - } - - #[test] - fn valid_export_node_passes() { - let inner = Dag { - nodes: vec![ - simple_node("inner_a", "t", vec![], vec![simple_port("result", "String")]), - ], - edges: vec![], - metadata: DagMetadata { - pattern_decisions: vec![PatternDecisionEntry { - tool: ToolId("t".into()), - pattern: "upsert".into(), - decision: PatternDecision::Instantiated, - }], - export_node: Some(NodeId("inner_a".into())), - }, - }; - let dag = Dag { - nodes: vec![ - subdag_node("wrapper", "t", vec![], vec![simple_port("result", "String")], inner), - ], - edges: vec![], - metadata: decisions_for(&["t"]), - }; - assert!(validate(&dag).is_ok()); - } - - #[test] - fn export_node_not_found() { - let inner = Dag { - nodes: vec![ - simple_node("inner_a", "t", vec![], vec![]), - ], - edges: vec![], - metadata: DagMetadata { - pattern_decisions: vec![PatternDecisionEntry { - tool: ToolId("t".into()), - pattern: "upsert".into(), - decision: PatternDecision::Instantiated, - }], - export_node: Some(NodeId("nonexistent".into())), - }, - }; - let dag = Dag { - nodes: vec![ - subdag_node("wrapper", "t", vec![], vec![], inner), - ], - edges: vec![], - metadata: decisions_for(&["t"]), - }; - let errs = validate(&dag).unwrap_err(); - assert!(errs.iter().any(|e| matches!(e, ValidationError::ExportNodeNotFound { wrapper_node, export_node } if wrapper_node == "wrapper" && export_node == "nonexistent"))); - } - - #[test] - fn export_node_missing_port() { - let inner = Dag { - nodes: vec![ - simple_node("inner_a", "t", vec![], vec![]), // no outputs - ], - edges: vec![], - metadata: DagMetadata { - pattern_decisions: vec![PatternDecisionEntry { - tool: ToolId("t".into()), - pattern: "upsert".into(), - decision: PatternDecision::Instantiated, - }], - export_node: Some(NodeId("inner_a".into())), - }, - }; - let dag = Dag { - nodes: vec![ - subdag_node("wrapper", "t", vec![], vec![simple_port("result", "String")], inner), - ], - edges: vec![], - metadata: decisions_for(&["t"]), - }; - let errs = validate(&dag).unwrap_err(); - assert!(errs.iter().any(|e| matches!(e, ValidationError::ExportNodeMissingPort { wrapper_node, export_node, port } if wrapper_node == "wrapper" && export_node == "inner_a" && port == "result"))); - } - - #[test] - fn missing_pattern_decision_rejected() { - let dag = Dag { - nodes: vec![ - simple_node("a", "tool_a", vec![], vec![]), - simple_node("b", "tool_b", vec![], vec![]), - ], - edges: vec![], - metadata: decisions_for(&["tool_a"]), // missing tool_b - }; - let errs = validate(&dag).unwrap_err(); - assert!(errs.iter().any(|e| matches!(e, ValidationError::MissingPatternDecision { tool } if tool == "tool_b"))); - } -} diff --git a/crates/gunbc-viz/Cargo.toml b/crates/gunbc-viz/Cargo.toml new file mode 100644 index 00000000000..a07c4bf3071 --- /dev/null +++ b/crates/gunbc-viz/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "gunbc-viz" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Interactive DAG visualizer for gunbc" + +[dependencies] +gunbc-ir = { workspace = true } +gunbc-exec = { workspace = true } +gunbc-transport = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +# Import all tool crates to discover their DAGs generically +gunbc-gist = { path = "../gunbc-gist" } +gunbc-buck2 = { path = "../gunbc-buck2" } +gunbc-makegen = { path = "../gunbc-makegen" } +gunbc-deps = { path = "../gunbc-deps" } +gunbc-ci = { path = "../gunbc-ci" } +gunbc-bootstrap = { path = "../gunbc-bootstrap" } + +[dev-dependencies] +gunbc-test = { workspace = true } + +[[bin]] +name = "gunbc-viz" +path = "src/main.rs" diff --git a/crates/gunbc-viz/src/discover.rs b/crates/gunbc-viz/src/discover.rs new file mode 100644 index 00000000000..4ff2361e5db --- /dev/null +++ b/crates/gunbc-viz/src/discover.rs @@ -0,0 +1,143 @@ +//! Generic DAG discovery. +//! +//! Discovers all DAGs by iterating the tool registry from gunbc-makegen. +//! This ensures the registry is the single source of truth for what tools exist. + +use crate::export::{export_dag, VizGraph}; +use gunbc_makegen::registry::ToolRegistry; + +/// Get the exported DAG for a tool by crate name. +/// +/// Returns None if the tool doesn't have a known DAG builder +/// (e.g., tools that are defined but not yet implemented). +fn get_viz_graph_for_tool(crate_name: &str) -> Option { + match crate_name { + "gunbc-gist" => { + let dag = gunbc_gist::build_gist_graph(vec![], false); + Some(export_dag(&dag, crate_name)) + } + "gunbc-buck2" => { + let dag = gunbc_buck2::build_buck2_graph(); + Some(export_dag(&dag, crate_name)) + } + "gunbc-makegen" => { + let dag = gunbc_makegen::build_makegen_graph(); + Some(export_dag(&dag, crate_name)) + } + "gunbc-deps" => { + let dag = gunbc_deps::build_deps_graph(); + Some(export_dag(&dag, crate_name)) + } + "gunbc-ci" => { + let dag = gunbc_ci::build_ci_graph(); + Some(export_dag(&dag, crate_name)) + } + "gunbc-bootstrap" => { + let dag = gunbc_bootstrap::build_bootstrap_graph(); + Some(export_dag(&dag, crate_name)) + } + "gunbc-viz" => { + let dag = crate::graph::build_viz_graph(); + Some(export_dag(&dag, crate_name)) + } + _ => None, // Unknown tool - skip it + } +} + +/// Discover all DAGs from the tool registry. +/// +/// Iterates the registry (single source of truth) and calls +/// the appropriate DAG builder for each tool. +pub fn discover_all_dags() -> Vec { + let registry = ToolRegistry::default_registry(); + + registry + .tools + .iter() + .filter_map(|tool| get_viz_graph_for_tool(&tool.crate_name)) + .collect() +} + +/// Check if a tool has a known DAG builder. +fn has_dag_builder(crate_name: &str) -> bool { + matches!( + crate_name, + "gunbc-gist" + | "gunbc-buck2" + | "gunbc-makegen" + | "gunbc-deps" + | "gunbc-ci" + | "gunbc-bootstrap" + | "gunbc-viz" + ) +} + +/// Get a list of tools from the registry that don't have DAG builders yet. +/// Useful for identifying what needs to be implemented. +pub fn missing_dag_builders() -> Vec { + let registry = ToolRegistry::default_registry(); + + registry + .tools + .iter() + .filter(|tool| !has_dag_builder(&tool.crate_name)) + .map(|tool| tool.crate_name.clone()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_discover_finds_all_registered_tools() { + let registry = ToolRegistry::default_registry(); + let dags = discover_all_dags(); + + // Should have a DAG for each tool in the registry + // (unless some tools don't have builders yet) + let missing = missing_dag_builders(); + let expected_count = registry.tools.len() - missing.len(); + + assert_eq!( + dags.len(), + expected_count, + "Expected {} DAGs (registry has {} tools, {} missing builders)", + expected_count, + registry.tools.len(), + missing.len() + ); + } + + #[test] + fn test_dag_names_match_registry() { + let registry = ToolRegistry::default_registry(); + let dags = discover_all_dags(); + + // Every discovered DAG should have a name matching a registry entry + for dag in &dags { + let in_registry = registry.tools.iter().any(|t| t.crate_name == dag.name); + assert!(in_registry, "DAG '{}' not in registry", dag.name); + } + } + + #[test] + fn test_discovered_dags_have_structure() { + let dags = discover_all_dags(); + + for dag in &dags { + assert!(!dag.nodes.is_empty(), "{} has no nodes", dag.name); + assert!(dag.meta.node_count > 0); + } + } + + #[test] + fn test_no_missing_builders() { + let missing = missing_dag_builders(); + assert!( + missing.is_empty(), + "Some tools are missing DAG builders: {:?}", + missing + ); + } +} diff --git a/crates/gunbc-viz/src/export.rs b/crates/gunbc-viz/src/export.rs new file mode 100644 index 00000000000..db9164a2dc9 --- /dev/null +++ b/crates/gunbc-viz/src/export.rs @@ -0,0 +1,290 @@ +//! Export DAG structures to JSON for visualization. + +use gunbc_ir::{detect_boundaries, detect_entrypoints, Dag, NodeBody}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +/// A graph exported for visualization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VizGraph { + /// Graph name/id + pub id: String, + /// Human-readable name + pub name: String, + /// Nodes in Cytoscape format + pub nodes: Vec, + /// Edges in Cytoscape format + pub edges: Vec, + /// Metadata about the graph + pub meta: VizMeta, +} + +/// A node for visualization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VizNode { + /// Node ID + pub id: String, + /// Display label + pub label: String, + /// Parent node ID (for compound/nested nodes) + pub parent: Option, + /// Node type: "opaque", "subdag", "port_in", "port_out" + #[serde(rename = "type")] + pub node_type: String, + /// Input ports + pub inputs: Vec, + /// Output ports + pub outputs: Vec, + /// Is this a boundary node? + pub is_boundary: bool, + /// Is this an entrypoint node? + pub is_entrypoint: bool, + /// Additional classes for styling + pub classes: Vec, +} + +/// A port for visualization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VizPort { + pub name: String, + #[serde(rename = "type")] + pub port_type: String, + pub is_entrypoint: bool, + pub is_boundary: bool, +} + +/// An edge for visualization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VizEdge { + /// Edge ID + pub id: String, + /// Source node + pub source: String, + /// Target node + pub target: String, + /// Source port name + pub source_port: String, + /// Target port name + pub target_port: String, + /// Data type flowing through + pub data_type: String, +} + +/// Metadata about the graph. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VizMeta { + pub node_count: usize, + pub edge_count: usize, + pub boundary_count: usize, + pub entrypoint_count: usize, + pub max_depth: usize, +} + +/// Export a DAG to visualization format. +pub fn export_dag(dag: &Dag, name: &str) -> VizGraph +where + T: std::fmt::Debug, +{ + let boundaries = detect_boundaries(dag); + let entrypoints = detect_entrypoints(dag); + + let boundary_nodes: HashSet<_> = boundaries.boundary_nodes.iter().collect(); + let entrypoint_ports: HashSet<_> = entrypoints + .entrypoint_ports + .iter() + .map(|(node_id, port_name, _)| (node_id.clone(), port_name.clone())) + .collect(); + + let mut viz_nodes = Vec::new(); + let mut viz_edges = Vec::new(); + let mut max_depth = 0; + + // Export nodes + for node in &dag.nodes { + let is_boundary = boundary_nodes.contains(&node.id); + let is_entrypoint = entrypoints + .entrypoint_ports + .iter() + .any(|(node_id, _, _)| *node_id == node.id); + + let mut classes = vec![]; + if is_boundary { + classes.push("boundary".to_string()); + } + if is_entrypoint { + classes.push("entrypoint".to_string()); + } + + let node_type = match &node.body { + NodeBody::Opaque(_) => "opaque", + NodeBody::SubDag(_) => "subdag", + }; + + if matches!(node.body, NodeBody::SubDag(_)) { + classes.push("compound".to_string()); + max_depth = max_depth.max(1); // TODO: recursive depth + } + + let inputs: Vec = node + .inputs + .iter() + .map(|p| VizPort { + name: p.name.to_string(), + port_type: p.type_id.to_string(), + is_entrypoint: entrypoint_ports.contains(&(node.id.clone(), p.name.clone())), + is_boundary: false, + }) + .collect(); + + let outputs: Vec = node + .outputs + .iter() + .map(|p| { + let is_boundary_port = boundaries + .boundary_ports + .iter() + .any(|(node_id, port_name)| *node_id == node.id && *port_name == p.name); + VizPort { + name: p.name.to_string(), + port_type: p.type_id.to_string(), + is_entrypoint: false, + is_boundary: is_boundary_port, + } + }) + .collect(); + + viz_nodes.push(VizNode { + id: node.id.to_string(), + label: node.id.to_string(), + parent: None, + node_type: node_type.to_string(), + inputs, + outputs, + is_boundary, + is_entrypoint, + classes, + }); + + // If it's a subdag, recursively export child nodes + if let NodeBody::SubDag(subdag) = &node.body { + let sub_export = export_dag(subdag, &node.id.0); + for mut child in sub_export.nodes { + child.parent = Some(node.id.to_string()); + child.id = format!("{}_{}", node.id, child.id); + viz_nodes.push(child); + } + for mut edge in sub_export.edges { + edge.source = format!("{}_{}", node.id, edge.source); + edge.target = format!("{}_{}", node.id, edge.target); + edge.id = format!("{}_{}", node.id, edge.id); + viz_edges.push(edge); + } + max_depth = max_depth.max(sub_export.meta.max_depth + 1); + } + } + + // Export edges + for (i, edge) in dag.edges.iter().enumerate() { + // Find the type from the source port + let data_type = dag + .get_node(&edge.from_node) + .and_then(|n| n.outputs.iter().find(|p| p.name == edge.from_port)) + .map(|p| p.type_id.to_string()) + .unwrap_or_else(|| "?".to_string()); + + viz_edges.push(VizEdge { + id: format!("e{}", i), + source: edge.from_node.to_string(), + target: edge.to_node.to_string(), + source_port: edge.from_port.to_string(), + target_port: edge.to_port.to_string(), + data_type, + }); + } + + VizGraph { + id: name.to_string(), + name: name.to_string(), + nodes: viz_nodes, + edges: viz_edges, + meta: VizMeta { + node_count: dag.nodes.len(), + edge_count: dag.edges.len(), + boundary_count: boundaries.boundary_nodes.len(), + entrypoint_count: entrypoints.entrypoint_ports.len(), + max_depth, + }, + } +} + +/// Collection of multiple graphs for the visualizer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VizCollection { + pub graphs: Vec, + pub generated_at: String, +} + +impl VizCollection { + pub fn new() -> Self { + Self { + graphs: Vec::new(), + generated_at: chrono_lite(), + } + } + + pub fn add(&mut self, graph: VizGraph) { + self.graphs.push(graph); + } +} + +impl Default for VizCollection { + fn default() -> Self { + Self::new() + } +} + +/// Simple timestamp without chrono dependency. +fn chrono_lite() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + format!("{}", duration.as_secs()) +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::{build::port, Edge, Node}; + + #[derive(Debug, Clone)] + enum TestOp { + A, + B, + } + + #[test] + fn test_export_simple_dag() { + let mut dag: Dag = Dag::new(); + dag.add_node(Node::opaque( + "a", + vec![port("in", "String")], + vec![port("out", "String")], + TestOp::A, + )); + dag.add_node(Node::opaque( + "b", + vec![port("in", "String")], + vec![port("out", "String")], + TestOp::B, + )); + dag.add_edge(Edge::new("a", "out", "b", "in")); + + let viz = export_dag(&dag, "test"); + + assert_eq!(viz.nodes.len(), 2); + assert_eq!(viz.edges.len(), 1); + assert_eq!(viz.meta.boundary_count, 1); // 'b' is boundary + assert_eq!(viz.meta.entrypoint_count, 1); // 'a.in' is entrypoint + } +} diff --git a/crates/gunbc-viz/src/graph.rs b/crates/gunbc-viz/src/graph.rs new file mode 100644 index 00000000000..55b44b8df73 --- /dev/null +++ b/crates/gunbc-viz/src/graph.rs @@ -0,0 +1,107 @@ +//! Graph builder for the visualization tool. + +use crate::ops::VizOp; +use gunbc_ir::{build::*, Dag, Edge, Node}; + +/// Build the visualization graph. +/// +/// Pipeline: +/// ```text +/// CollectDags -> ExportJson -> PrepareFileWrite -> ExecuteTransport +/// ↓ +/// (boundary) +/// ``` +pub fn build_viz_graph() -> Dag { + let mut dag = Dag::new(); + + // Node: CollectDags + dag.add_node(Node::opaque( + "collect_dags", + vec![], + vec![ + port("graph_count", "Int"), + port("graph_names", "StrList"), + port("graphs", "Json"), + ], + VizOp::CollectDags, + )); + + // Node: ExportJson + dag.add_node(Node::opaque( + "export_json", + vec![port("graphs", "Json")], + vec![port("json_content", "String")], + VizOp::ExportJson, + )); + + // Node: PrepareFileWrite (PURE - no I/O) + dag.add_node(Node::opaque( + "prepare_file_write", + vec![ + port("json_content", "String"), + port("output_path", "String"), + ], + vec![port("request", "TransportRequest")], + VizOp::PrepareFileWrite, + )); + + // Node: ExecuteTransport (BOUNDARY - world write) + dag.add_node(Node::opaque( + "execute_transport", + vec![port("request", "TransportRequest")], + vec![ + port("response", "TransportResponse"), + port("written_path", "String"), + ], + VizOp::ExecuteTransport, + )); + + // Wire up the pipeline + dag.add_edge(Edge::new("collect_dags", "graphs", "export_json", "graphs")); + dag.add_edge(Edge::new( + "export_json", + "json_content", + "prepare_file_write", + "json_content", + )); + dag.add_edge(Edge::new( + "prepare_file_write", + "request", + "execute_transport", + "request", + )); + + dag +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::{detect_boundaries, detect_entrypoints}; + + #[test] + fn test_graph_has_boundary() { + let dag = build_viz_graph(); + let boundaries = detect_boundaries(&dag); + + // ExecuteTransport should be a boundary + assert!(boundaries.is_boundary_node(&"execute_transport".into())); + } + + #[test] + fn test_graph_has_entrypoints() { + let dag = build_viz_graph(); + let entrypoints = detect_entrypoints(&dag); + + // output_path on prepare_file_write is an entrypoint + assert!(entrypoints.is_entrypoint_port(&"prepare_file_write".into(), &"output_path".into())); + } + + #[test] + fn test_graph_structure() { + let dag = build_viz_graph(); + + assert_eq!(dag.nodes.len(), 4); + assert_eq!(dag.edges.len(), 3); + } +} diff --git a/crates/gunbc-viz/src/lib.rs b/crates/gunbc-viz/src/lib.rs new file mode 100644 index 00000000000..babc5a6772f --- /dev/null +++ b/crates/gunbc-viz/src/lib.rs @@ -0,0 +1,17 @@ +//! gunbc-viz: Interactive DAG visualizer. +//! +//! This crate provides: +//! - JSON export of DAG structures for visualization +//! - Generic DAG discovery from all tool crates +//! - An interactive HTML/JS visualizer using Cytoscape.js +//! - Support for fractal/nested DAGs (compound nodes) + +pub mod discover; +pub mod export; +pub mod graph; +pub mod ops; + +pub use discover::discover_all_dags; +pub use export::{export_dag, VizCollection, VizGraph}; +pub use graph::build_viz_graph; +pub use ops::VizOp; diff --git a/crates/gunbc-viz/src/main.rs b/crates/gunbc-viz/src/main.rs new file mode 100644 index 00000000000..174074d7da0 --- /dev/null +++ b/crates/gunbc-viz/src/main.rs @@ -0,0 +1,139 @@ +//! CLI for gunbc-viz - generates DAG visualization data. + +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_ir::transport::{FileResponse, TransportResponse}; +use gunbc_ir::Value; +use gunbc_viz::build_viz_graph; +use std::env; +use std::process; + +fn main() { + let args: Vec = env::args().collect(); + + let mut output_path = "viz-data.json".to_string(); + let mut dry_run = false; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "-o" | "--output" => { + i += 1; + if i < args.len() { + output_path = args[i].clone(); + } + } + "-n" | "--dry-run" => { + dry_run = true; + } + "-h" | "--help" => { + print_help(); + return; + } + _ => {} + } + i += 1; + } + + // Build the DAG + let dag = build_viz_graph(); + + // Set up execution mode + let mode = if dry_run { + let mut mocks = BoundaryMocks::new(); + mocks.set_value( + "execute_transport", + "written_path", + Value::Str("".to_string()), + ); + mocks.set_value( + "execute_transport", + "response", + Value::Response(TransportResponse::File(FileResponse::written(&output_path))), + ); + ExecutionMode::DryRun(mocks) + } else { + ExecutionMode::Real + }; + + println!("gunbc-viz: DAG Visualization Generator"); + println!(" output: {}", output_path); + println!(" mode: {}", if dry_run { "dry-run" } else { "real" }); + println!(); + + match execute_with_mode(&dag, mode) { + Ok(log) => { + for entry in &log.entries { + let marker = if entry.was_intercepted { + " [DRY-RUN]" + } else { + "" + }; + println!("[{}]{}", entry.node_id, marker); + + // Print summary of outputs + for (port, value) in &entry.outputs { + match value { + Value::Str(s) if s.len() < 80 => println!(" {}: {}", port, s), + Value::Str(s) => println!(" {}: {}...", port, &s[..60]), + Value::Int(i) => println!(" {}: {}", port, i), + Value::StrList(list) => println!(" {}: [{} items]", port, list.len()), + Value::Json(_) => println!(" {}: ", port), + _ => {} + } + } + } + + // Print final result + if let Some(entry) = log.get("execute_transport") { + if let Some(Value::Str(path)) = entry.outputs.get("written_path") { + println!(); + if entry.was_intercepted { + println!("Would write to: {}", output_path); + } else { + println!("Written: {}", path); + } + } + } + + // Print collected graphs + if let Some(entry) = log.get("collect_dags") { + if let Some(Value::StrList(names)) = entry.outputs.get("graph_names") { + println!(); + println!("Graphs collected:"); + for name in names { + println!(" - {}", name); + } + } + } + + // Print serve instructions + if !dry_run { + println!(); + println!("To view the visualization:"); + println!(" make viz-serve"); + println!(); + println!("Or manually:"); + println!(" python3 -m http.server 8080 &"); + println!(" open http://localhost:8080/viz.html"); + } + } + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } +} + +fn print_help() { + println!("gunbc-viz - Generate DAG visualization data"); + println!(); + println!("USAGE:"); + println!(" gunbc-viz [OPTIONS]"); + println!(); + println!("OPTIONS:"); + println!(" -o, --output Output JSON path (default: viz-data.json)"); + println!(" -n, --dry-run Don't actually write the file"); + println!(" -h, --help Print this help"); + println!(); + println!("After running, open viz.html in a browser to view the visualization."); +} diff --git a/crates/gunbc-viz/src/ops.rs b/crates/gunbc-viz/src/ops.rs new file mode 100644 index 00000000000..c09b5bfa61c --- /dev/null +++ b/crates/gunbc-viz/src/ops.rs @@ -0,0 +1,159 @@ +//! Visualization operations. + +use crate::discover::discover_all_dags; +use crate::export::VizCollection; +use gunbc_exec::{ExecError, Executable}; +use gunbc_ir::transport::{FileRequest, FileResponse, TransportRequest, TransportResponse}; +use gunbc_ir::Value; +use gunbc_transport::execute_transport; +use std::collections::HashMap; + +/// Operations for the visualization tool. +#[derive(Debug, Clone)] +pub enum VizOp { + /// Collect all DAG definitions from the workspace (discovers them generically) + CollectDags, + /// Export DAGs to JSON format + ExportJson, + /// Prepare file write request (PURE - no I/O) + PrepareFileWrite, + /// Execute transport (BOUNDARY - world write) + ExecuteTransport, +} + +impl Executable for VizOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + match self { + VizOp::CollectDags => execute_collect_dags(inputs), + VizOp::ExportJson => execute_export_json(inputs), + VizOp::PrepareFileWrite => execute_prepare_file_write(inputs), + VizOp::ExecuteTransport => execute_transport_op(inputs), + } + } +} + +/// Collect all DAG definitions by discovering them from the actual graph builders. +fn execute_collect_dags(_inputs: HashMap) -> Result, ExecError> { + // Use the generic discovery mechanism + let graphs = discover_all_dags(); + + let graph_names: Vec = graphs.iter().map(|g| g.name.clone()).collect(); + + let mut out = HashMap::new(); + out.insert("graph_count".to_string(), Value::Int(graphs.len() as i64)); + out.insert("graph_names".to_string(), Value::StrList(graph_names)); + out.insert( + "graphs".to_string(), + Value::Json(serde_json::to_value(&graphs).unwrap_or_default()), + ); + Ok(out) +} + +/// Export DAGs to JSON format. +fn execute_export_json(inputs: HashMap) -> Result, ExecError> { + let graphs: Vec = match inputs.get("graphs") { + Some(Value::Json(j)) => serde_json::from_value(j.clone()).unwrap_or_default(), + _ => return Err(ExecError::new("missing or invalid 'graphs' input")), + }; + + let collection = VizCollection { + graphs, + generated_at: chrono_lite(), + }; + + let json_content = serde_json::to_string_pretty(&collection) + .map_err(|e| ExecError::new(format!("JSON serialization failed: {}", e)))?; + + let mut out = HashMap::new(); + out.insert("json_content".to_string(), Value::Str(json_content)); + Ok(out) +} + +/// Prepare file write request. +fn execute_prepare_file_write( + inputs: HashMap, +) -> Result, ExecError> { + let json_content = match inputs.get("json_content") { + Some(Value::Str(s)) => s.clone(), + _ => return Err(ExecError::new("missing or invalid 'json_content' input")), + }; + + let output_path = match inputs.get("output_path") { + Some(Value::Str(s)) => s.clone(), + _ => "viz-data.json".to_string(), + }; + + let request = TransportRequest::File(FileRequest::write(&output_path, json_content)); + + let mut out = HashMap::new(); + out.insert("request".to_string(), Value::Request(request)); + Ok(out) +} + +/// Execute transport request. +fn execute_transport_op( + inputs: HashMap, +) -> Result, ExecError> { + let request = match inputs.get("request") { + Some(Value::Request(r)) => r.clone(), + _ => return Err(ExecError::new("missing or invalid 'request' input")), + }; + + let response = execute_transport(&request) + .map_err(|e| ExecError::new(format!("transport error: {}", e)))?; + + let written_path = match &response { + TransportResponse::File(FileResponse { path, .. }) => path.clone(), + _ => "unknown".to_string(), + }; + + let mut out = HashMap::new(); + out.insert("response".to_string(), Value::Response(response)); + out.insert("written_path".to_string(), Value::Str(written_path)); + Ok(out) +} + +/// Simple timestamp. +fn chrono_lite() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + format!("{}", duration.as_secs()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_collect_dags() { + let result = execute_collect_dags(HashMap::new()).unwrap(); + + match result.get("graph_count") { + Some(Value::Int(n)) => assert!(*n >= 7, "Expected at least 7 DAGs"), + _ => panic!("expected graph count"), + } + } + + #[test] + fn test_export_json() { + let collect_result = execute_collect_dags(HashMap::new()).unwrap(); + + let mut inputs = HashMap::new(); + inputs.insert( + "graphs".to_string(), + collect_result.get("graphs").unwrap().clone(), + ); + + let result = execute_export_json(inputs).unwrap(); + + match result.get("json_content") { + Some(Value::Str(s)) => { + assert!(s.contains("gunbc-gist")); + assert!(s.contains("graphs")); + } + _ => panic!("expected JSON content"), + } + } +} diff --git a/deps.toml b/deps.toml new file mode 100644 index 00000000000..3ca16d2b101 --- /dev/null +++ b/deps.toml @@ -0,0 +1,38 @@ +# gunbc tool dependencies +# Install with: gunbc-deps install + +[[dependency]] +name = "cargo" +verify = "cargo --version" + +[dependency.install.linux] +method = "script" +script = "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y" + +[dependency.install.macos] +method = "brew" +packages = ["rustup"] + +[[dependency]] +name = "gh" +verify = "gh --version" + +[dependency.install.linux] +method = "apt" +packages = ["gh"] + +[dependency.install.macos] +method = "brew" +packages = ["gh"] + +[[dependency]] +name = "git" +verify = "git --version" + +[dependency.install.linux] +method = "apt" +packages = ["git"] + +[dependency.install.macos] +method = "brew" +packages = ["git"] diff --git a/docs/ac.pdf b/docs/ac.pdf deleted file mode 100644 index 2aa1afab406..00000000000 Binary files a/docs/ac.pdf and /dev/null differ diff --git a/docs/gistgen-plan.md b/docs/gistgen-plan.md deleted file mode 100644 index adad81f3a8c..00000000000 --- a/docs/gistgen-plan.md +++ /dev/null @@ -1,95 +0,0 @@ -# Gistgen Plan (v0) - -**Status**: Draft — January 2026 -**Purpose**: Minimal, implementable plan for `gistgen` grounded in gunbc. -Small on purpose. - ---- - -## 1. Goals / Non-Goals - -**Goals** -- Emit a shareable Gist URL quickly. -- Auth is Upsert-shaped capability acquisition. -- `gistgen` is a non-idempotent Emit (never Upsert in v0). -- Treat GitHub/Git as opaque unless semantics are required. - -**Non-goals (v0)** -- Cross-run dedupe (hash/commit based). -- Full Git object/refs semantics. - ---- - -## 2. Concepts (only what v0 uses) - -- **Context**: rooted observation source (env, args, time). -- **Repo**: repo identity (path, optional ref later). -- **SelectionSpec**: include/exclude rules. -- **RepoSnapshot**: selected files + contents (Repo + Snapshot). -- **Secret\**: capability needed for API calls. -- **GistUrl**: handle returned by GitHub. - ---- - -## 3. Pattern Decisions (forced) - -- `auth`: **Instantiated** (Upsert — capability resource). -- `gistgen`: **NotApplicable** — reason: "non-idempotent snapshot emission." - ---- - -## 4. Program DAG (top-level) - -``` -Context ──env──> Auth (Upsert) ──token──────────┐ -Context ──args──> ParseArgs ──repo──────────────┼──> Gistgen (Emit) ──> GistUrl - ──selection_spec────┘ -``` - -Node contracts (minimal): -- `context`: `() -> (Env, Args, Time)` -- `parse_args`: `Args -> (Repo, SelectionSpec)` -- `auth`: `Env -> Secret` — Upsert-shaped -- `gistgen`: `(Repo, SelectionSpec, Secret) -> GistUrl` — NonIdempotent write - ---- - -## 5. `gistgen` Emit Sub-DAG - -``` -enumerate_files → filter_files → read_files → compose_snapshot → upload_gist -``` - -- `enumerate_files`: Observe -- `filter_files`: Pure -- `read_files`: Observe -- `compose_snapshot`: Pure (Repo + Snapshot → RepoSnapshot) -- `upload_gist`: WritesWorld + NonIdempotent - ---- - -## 6. Validation + Runtime Rules (exercised in v0) - -- Every node must declare a `PatternDecision` (no silent defaults). -- `WritesWorld` nodes require explicit idempotency stance and policy approval. -- `Secret` values are never displayed in logs, errors, or debug output. -- IR checks: type agreement, port saturation, acyclicity. -- Auth token exists → downstream proceeds; otherwise blocked. - ---- - -## 7. Implementation Order (walking skeleton) - -1. Minimal IR: Node, Dag, Port, Edge. -2. `PatternDecision` plumbing + validation. -3. Auth Upsert node (`Env -> Secret`). -4. Gistgen emit pipeline ending in `upload_gist`. -5. CLI builds the DAG and executes it. - ---- - -## 8. Future Refinements (explicitly out of v0) - -- Open repo semantics (RepoRef, TreeHash, etc.). -- Dedupe by upgrading gistgen to Upsert (wrap Create). -- Factor a reusable Emit pattern if it emerges. diff --git a/docs/v3-contracts-minimal.md b/docs/v3-contracts-minimal.md deleted file mode 100644 index 0bfc0008f69..00000000000 --- a/docs/v3-contracts-minimal.md +++ /dev/null @@ -1,457 +0,0 @@ -# V3 Contracts — Minimal Design - -**Status**: Draft — January 2026 -**Purpose**: The logically minimal representation of the V2 design. -If a statement can be removed without breaking coherence, it doesn't belong. - -**Full rationale**: [`v2-contracts-design.md`](./v2-contracts-design.md) -**Problem analysis**: [`bl1-retrospective.md`](./bl1-retrospective.md) -**Worked examples**: [`v3-worked-examples.md`](./v3-worked-examples.md) -**Foundational inspiration**: [`ac.pdf`](./ac.pdf) — Abstraction Calculus - ---- - -## Relationship to the Abstraction Calculus - -V3 is an instantiation of the AC ([`ac.pdf`](./ac.pdf)). The AC defines a -domain-agnostic framework for towers of abstraction. V3 instantiates it for -causal modeling of tool behaviors. Every structural choice in V3 traces to -an AC primitive: - -**The primitive.** AC defines a primitive as Π = (U, B, L, mode) — a -universe, a codomain, a lens, and a mode. In V3, a `Node` is a -primitive: - -- **U** (universe) = the sub-DAG inside the node (or the opaque - implementation if it's a leaf) -- **B** (codomain) = the typed output ports -- **L** (lens) = the compilation/flattening that extracts output from - internal structure. `ResolvedHandle` is L applied to the upsert - sub-DAG. The lens compresses everything inside into the output type. -- **mode** = whether this node's contract is descriptive ("what the tool - does") or normative ("what the tool must satisfy"). V1 was descriptive. - V3 is normative. Pattern templates are the mode-bridge. - -**The kernel quotient (α).** AC defines abstraction as: x ~ y iff -L(x) = L(y). In V3, two sub-DAG implementations are equivalent if they -produce the same typed output on the same typed input. This is -**fungibility** — you can swap an opaque node for a sub-DAG (or vice -versa) without breaking consumers, because the consumer sees only L's -output (the ports), not U (the internals). - -**Idempotence (α∘α = α).** Once a node is opaque, further compression is -trivial — the node is already its own equivalence class. Abstracting an -already-abstract node changes nothing. - -**Re-priming.** AC says: after α, use the new carrier A₁ as the next -universe, apply a new lens. In V3, this is **opening a node**: the -sub-DAG becomes the new universe, each inner node gets its own lens -(output ports), and you repeat. Each level of the tower is one -re-priming step. The tower is the iterated application of α with -re-priming. - -**Contexts.** AC defines contexts C ∈ Ctx that determine the carrier and -codomain. In V3, context is the platform/environment (Linux, macOS, -CI, local). A node's sub-DAG may differ per context. The lowering phase -resolves context — it selects the relevant sub-DAG for the current -platform. **Naturality** (the lens commutes with context restriction) -means the output type is context-independent: `ResolvedHandle` regardless -of platform. **Context meet** (C₁ ∧ C₂) means composing nodes from -different contexts requires a common context; if none exists, the -composition is undefined. - -**Non-triviality guard.** AC says: don't apply α when the dispersion -score H(L) drops below a floor. In V3, this is **P3: no speculative -patterns**. Don't add a level of abstraction unless it compresses -something useful. 2+ tools justify a pattern; 1 tool doesn't. - -**Stratified reflection.** AC says: level k may only evaluate objects at -level < k. In V3, the compiler (modeling layer) validates pattern specs. -The executor runs opaque leaves. The executor never evaluates modeling -concepts. No circular dependencies between levels. - -**Set operations.** AC says: when B is a De Morgan algebra, set-like -operations are available. In V3, `SetSpec` (from V1) provides -Universal/Empty/These with proper set semantics. Already structural. - ---- - -## What We Do - -We model cause and effect. - -Not objects. Not data. Not "things that exist." We model **what causes -what, in what order, carrying what information**. A DAG is not a data -structure — it is a claim about time-gated causation. Node A must -complete before Node B can begin. The edge between them carries typed -information that B needs from A. That's it. - -Every system does things in order. A causes B. B causes C. If something -goes wrong, you have to be able to point at the chain and say where: - -- Did something happen between A and B? -- Between B and C? -- Before A? -- Did something happen *while* B was happening that we forgot to mention? - -If you can't answer these questions from the model, the model is incomplete. - ---- - -## Why Not Just Write Code - -Normal programming also has causal structure. Functions call functions. -Data flows through arguments and return values. The structure exists — -but it's **inside the code**, invisible to anything except a human reading it. - -You can't ask a program "what must happen before X?" without reading the -implementation. You can't ask "if Y fails, what is affected?" without -tracing control flow. The call graph is implicit, recoverable only by -static analysis tools that are always incomplete (Turing-completeness -guarantees this). - -In the fractal DAG, the causal structure **is** the data: - -- Every dependency is an edge. You query it. -- Every output is a typed port. You check it. -- Every conditional skip is a declared guard. You enumerate them. -- Completeness is structural — if a dependency isn't an edge, it doesn't exist. - -This is the difference between *code that runs* and *code that reasons -about itself*. A program that only runs can do anything but prove nothing. -A program that is also a model of itself can answer questions about its -own structure before execution — what's missing, what breaks if this -changes, what tests are implied, what invariants hold. - -**Normal programming: humans reason about code.** -**This: code reasons about code, because the structure is the data.** - -The trade-off is expressiveness. DAGs can't loop. Typed ports can't lie. -Explicit edges can't hide. These constraints make deduction possible. -We give up Turing-complete flexibility in the modeling layer to gain -machine-checkable guarantees — completeness checking, impact analysis, -test generation, invariant propagation — all derived from the graph, -not maintained by hand. - -The execution layer remains Turing-complete (opaque nodes can do -anything). The modeling layer is deliberately constrained so that -the system can reason about its own structure. That's the point. - -### What Can Go Wrong - -The above guarantees hold only if opaque nodes honor the contract. -Three failure modes: - -**1. Hidden side effects.** If Node A writes `/tmp/data.json` and -Node B reads it, but there's no edge between them, the graph says -they're independent. The scheduler may parallelize them. Race condition. -The graph is only a source of truth if nodes communicate exclusively -through declared ports. Enforcement options: sandboxed execution, -declared I/O manifests, or runtime detection of undeclared filesystem -access. Without enforcement, the graph is a suggestion, not a guarantee. - -**2. God nodes.** The value of the graph is proportional to how much -structure is in the graph vs hidden inside opaque nodes. A DAG with -two nodes ("Setup" → "Run Everything") is technically a DAG but tells -you nothing. Impact analysis says "this giant node changed" — useless. -The fractal structure mitigates this (you can always open a node into -a sub-DAG), but only if someone actually does. Preventing god nodes -requires review discipline or structural limits (max complexity per -opaque node, mandatory decomposition above a threshold). - -**3. Dynamic topology.** DAGs are static. Workflow automation sometimes -requires variable-sized work: "for every user, send an email." Three -options: hide the loop inside a node (lose per-item failure tracking), -unroll into N nodes (graph explosion), or support dynamic DAG generation -at runtime (lose static analysis guarantees for that subgraph). This is -a real tension. Our current instantiation uses static topology — -variable-sized work lives inside opaque nodes. If per-item causal -tracking becomes necessary, dynamic subgraph generation is the escape -hatch, with the understanding that dynamically generated subgraphs -are not statically analyzable. - -**The contract of the node:** For the graph to be a source of truth, -each opaque node must be stateless across the graph boundary — all -input arrives through declared ports, all output leaves through declared -ports, no side-channel communication. The node can do anything internally -(Turing-complete), but its interface to the graph must be honest. -Without this contract, the DAG is a visualization, not a reasoning tool. - ---- - -## Two Kinds of Cause - -Every causal link has two aspects: - -- **Action** (energy) — movement. "A causes B to happen." Directed, - sequential, kinetic. Represented by **edges** in the chain. -- **Information** (matter) — structure. "What A produces *is* this." - Static, constraining, determining. Represented by **types** on edges. - -An edge without a type is "something happens next" — no constraint. -A type without an edge is "this exists" — no movement. You need both. -The edge says *something flows*. The type says *what flows*. - -**Every cause must be an explicit link — both the action and the -information.** If A must happen before B, there is an edge. If A produces -something B needs, the type is declared on both sides. If B might not -happen (because A said "nothing to do"), that skip is an explicit guard -on the edge, not an implicit absence. - -No implicit ordering. No implicit data flow. No implicit skips. - ---- - -## One Type - -There is one data model in the entire system: - -```rust -struct Node { - id: NodeId, - inputs: Vec, - outputs: Vec, - body: NodeBody, -} - -enum NodeBody { - Opaque(T), // leaf — we trust it, don't look inside - SubDag(Dag), // recursive — same structure inside -} - -struct Dag { - nodes: Vec>, - edges: Vec, -} -``` - -That's the whole system. There is no separate "Understanding" type, no -separate "Behavior" type, no separate "Block" type. There is `Node` and -`Dag`. A node is either opaque (you trust it) or a sub-DAG (you can -look inside, and it's the same structure). - -What were previously three different representations: - -| Old type | New representation | -|---|---| -| `Understanding` | `Node` with `SubDag` body containing operations | -| `Behavior` | `Node` inside a tool's sub-DAG | -| `Block` | `Node` — opaque leaf, or sub-DAG of finer steps | -| GraphIR | `Dag` — the result of flattening all sub-DAGs | -| `gunbai-dag` | Generic algorithms over `Dag` (topo sort, waves, etc.) | - -One type. One graph. Different zoom levels. - ---- - -## One Interface (The Fractal Structure) - -The system is not a stack of different machines. It is one recursive -structure. Every level has the same interface: - -``` -Node = typed input ports → [black box] → typed output ports -DAG = nodes + typed edges connecting ports -``` - -A node is either: - -- **Opaque** — a leaf. You trust it to do its job. This is your L0. -- **A sub-DAG** — same interface inside. If you look inside, you've - moved your L0 down one level. - -We are not building a tool engine and an operation engine and a block -engine. We are building a **fractal DAG engine** — one engine that -understands nodes with typed ports, all the way up and down. - -**The type at a level boundary is the complete interface.** A node's -output type tells you everything you need to operate at that level. -You never reach through it. If you need to know what happened inside -a node, you're working at the level below — you've opened the sub-DAG. -That's a different stance, not a violation. - -Three properties follow from one interface: - -- **Uniformity**: the DSL does not change between layers. You are always - composing nodes into DAGs. -- **Opacity**: a node is "atomic" only because you choose not to look - inside. To `depgen`, "install git" is an atomic node. To the executor, - it's a DAG of operations. Same node, different stance. -- **Fungibility**: because the interface (typed ports) is identical at - every level, you can replace an opaque node with a sub-DAG (or - collapse a sub-DAG into an opaque node) without breaking consumers. - The consumer sees the contract, not the implementation. - -This is how physics works. A gas is a fluid (L2) or a collection of -molecules (L1). The interface (pressure, volume, temperature) works at -L2 without knowing about L1. You can model the gas as one node or as -a molecular simulation. The equations at L2 don't change. - -``` -L3: [tool/zstd] ----"exists"----> [tool/tectonic] - | - | (open the node) - v -L2: [Check] --[ResourceState]--> [Create] --[ResourceRef]--> [Resolve] - | | - | (open the node) | - v v -L1: [Block: "which zstd"] --[Output]--> ... [Block: "zstd --version"] - | - | (open the node — or don't, this is our current L0) - v -L0: (opaque: we trust the shell) -``` - -Every box is the same shape. Every arrow is the same kind of thing. -The only difference between levels is *what you choose to model*. - ---- - -## The Tower - -Causal structure forms a tower of levels. Each level compresses the -action/matter of the level below into typed matter for the level above. - -There is no privileged base. **L0 is wherever you choose to stop looking** -— the opacity boundary where you say "I trust everything below here." -The tower doesn't have a natural bottom. It has a *chosen* bottom. - -From your chosen L0, you build upward: - -- **Each level is one abstraction step** (the AC's α — kernel quotient). - It takes the action/matter detail below and presents compressed typed - matter above. -- **You add levels where you need causal structure.** If you need to - reason about what happens *inside* a node at level k, you decompose it - into a sub-DAG at level k-1. -- **You stop when compression stops paying.** When further decomposition - doesn't help you answer causal questions, you've found your top. - -### Our instantiation - -We currently instantiate the tower with these levels: - -| Level | What it captures | Unit of matter | Unit of action | -|---|---|---|---| -| L0 | Shell commands, API calls | Exit codes, bytes, responses | "Execute this" | -| L1 | Execution blocks | Typed ports (Output, ResourceRef) | Block-to-block data flow | -| L2 | Operations within a tool | Typed state (ResourceState) | Causal edges (check → create) | -| L3 | Tool dependencies | "Tool exists" | "Install before" | - -L0 and L1 exist (GraphIR + `gunbai-dag`). L3 exists (`depends_on` graph). -**L2 was missing.** That's what we're adding. - -But with one type (`Node` / `Dag`), the "levels" are just how deep -you've opened the sub-DAGs. There's no code that knows about levels. -There's just the recursive structure. - -### Why L2 was the gap - -We built L0→L1 (execution engine) and L3 (tool dependencies), then jumped -from L3 to L1 by converting each behavior into an isolated block -(`to_blocks()`). That jump skipped L2 — the causal structure *within* -a tool. - -The fix isn't "add L2." The fix is "every node is a sub-DAG or opaque." -If we'd had one recursive type from the start, L2 wouldn't have been -skippable — opening a tool node would have forced you to provide its -internal DAG. - ---- - -## Patterns Are Reusable Sub-DAG Templates - -A pattern is a sub-DAG template — a reusable shape that tools fill in. -Upsert is: - -``` -Check --[ResourceState]--> Create --[ResourceRef]--> Resolve --[ResolvedHandle]--> - | ^ - | (guard: only if Missing) | - +--- if Exists, skip Create ------------------+ -``` - -The template defines the shape: nodes, edges, types, guards. A tool -fills in what each node actually does. Behaviors are the nodes produced -by instantiation — not an author-written flat list. - -From the level above, the entire upsert chain is one node with output -type `ResolvedHandle`. Same interface. You don't need to know -check/create/resolve happened. - ---- - -## What This Means Concretely - -### 1. If it's not in a chain, you have to say so explicitly - -Every tool must address every known pattern: either "I implement this -chain" (with all nodes filled in) or "this chain doesn't apply to me" -(explicit decision, not silence). - -### 2. No freeform names for things that matter - -If generators, validators, or tests consume a field, that field is a -typed value — an enum, a validated ID, a declared code. Not a string. -Strings are for documentation only. - -### 3. Every claim has proof - -If you say a behavior is read-only, you say how that's verified: a -generated test, a harness, a derivation from other proven properties, -or a justified exception with an owner and expiry. - ---- - -## The Compiler - -The compiler is a recursive traversal of the DAG-of-DAGs: - -``` -Author writes: "this tool implements upsert; here are the bindings" - → Node with SubDag body - ↓ -Modeling: validates each sub-DAG is complete (all nodes, all types, all edges) - ↓ -Lowering: recursively flattens SubDag bodies into Opaque leaves; - resolves guards into skip predicates; strips irrelevant branches - → Dag (all nodes are Opaque) - ↓ -Execution: gunbai-dag runs the flat Dag in dependency order - (sees only nodes and edges — no levels, no patterns, no recursion) -``` - -The execution engine sees one flat DAG of opaque nodes. The fractal -structure exists only at authoring and compilation time. - ---- - -## What's Banned - -- Incomplete sub-DAGs (upsert without resolve = type error) -- Implicit skips (guarded nodes declare their guard) -- String-typed semantic fields (enums or declared codes, not `&str`) -- Convention-based protocols (no prefix parsing) -- Unverified property claims (every claim carries proof strategy) -- Nodes without causal provenance (no flat lists, no bag of behaviors) -- Speculative patterns (2+ tools before formalization) -- Leaky level boundaries (a node's output type is the complete interface) -- Separate type systems per level (one `Node` / `Dag`, not - Understanding + Behavior + Block) - ---- - -## Extension - -New semantic values: declare a typed code (Lane B), use it. If 2+ tools -use it, promote to core enum (Lane A). Strings are Lane C (docs only). - -New patterns: same rule. Declare it typed, implement one tool. Second -tool triggers promotion. - -New levels: open a node into a sub-DAG. Same type. Same interface. -The framework doesn't change — there's nothing to change. - -First tool is never blocked. Core vocabulary never bloats without evidence. -The tower grows when the domain demands it, not before. diff --git a/docs/v3-worked-examples.md b/docs/v3-worked-examples.md deleted file mode 100644 index cfec704584d..00000000000 --- a/docs/v3-worked-examples.md +++ /dev/null @@ -1,353 +0,0 @@ -# V3 Worked Examples - -**Status**: Draft — January 2026 -**Purpose**: Concrete examples of the fractal DAG model from -[`v3-contracts-minimal.md`](./v3-contracts-minimal.md). Shows what tools -look like when Understanding, Behavior, and Block are all the same type. - ---- - -## The One Type - -Every example uses the same structure: - -```rust -struct Node { - id: NodeId, - inputs: Vec, - outputs: Vec, - body: NodeBody, -} - -enum NodeBody { - Opaque(T), - SubDag(Dag), -} - -struct Dag { - nodes: Vec>, - edges: Vec, -} -``` - ---- - -## Example 1: `tool/zstd` — Simple Tool (One Pattern) - -### At L3 (tool dependency graph) - -Other tools see zstd as one opaque node: - -``` -Node { - id: "tool/zstd", - inputs: [Port("os", PlatformKind)], - outputs: [Port("binary", ResolvedHandle)], - body: SubDag(...) // ← we can open this -} -``` - -From L3's perspective, zstd takes a platform and produces a binary handle. -That's the complete contract. Consumers (like tectonic) connect to -`outputs["binary"]` and never look inside. - -### Open the node → L2 (operations) - -Inside, it's an upsert pattern — a sub-DAG of three nodes: - -``` -Dag { - nodes: [ - Node { - id: "tool/zstd/check", - inputs: [], - outputs: [Port("state", ResourceState)], - body: SubDag(...) // ← can open further - }, - Node { - id: "tool/zstd/create", - inputs: [Port("state", ResourceState, guard: Equals(Missing))], - outputs: [Port("ref", GuardedOutput)], - body: SubDag(...) - }, - Node { - id: "tool/zstd/resolve", - inputs: [ - Port("state", ResourceState), - Port("ref", GuardedOutput), - ], - outputs: [Port("handle", ResolvedHandle)], - body: SubDag(...) - }, - ], - edges: [ - "check/state" → "create/state", - "check/state" → "resolve/state", - "create/ref" → "resolve/ref", - ], -} -``` - -Same `Node` type. Same `Dag` type. The upsert pattern is just a -particular DAG shape — three nodes, specific port types, one guard. - -### Open "check" → L1 (execution blocks) - -``` -Dag { - nodes: [ - Node { - id: "tool/zstd/check/which", - inputs: [], - outputs: [Port("output", CommandOutput)], - body: Opaque(ShellCommand("which zstd")) // ← L0, we stop here - }, - Node { - id: "tool/zstd/check/parse", - inputs: [Port("output", CommandOutput)], - outputs: [Port("state", ResourceState)], - body: Opaque(ParseExitCode { - success: ResourceState::Exists, - failure: ResourceState::Missing, - }) - }, - ], - edges: [ - "which/output" → "parse/output", - ], -} -``` - -Still the same types. `Opaque(ShellCommand(...))` is our chosen L0 — -we trust the shell to execute it. We could open it further (syscalls, -kernel scheduling) but we choose not to. - -### Open "create" → L1 - -``` -Dag { - nodes: [ - Node { - id: "tool/zstd/create/download", - inputs: [], - outputs: [Port("archive", FilePath)], - body: Opaque(ShellCommand("curl -L https://... -o /tmp/zstd.tar.gz")) - }, - Node { - id: "tool/zstd/create/extract", - inputs: [Port("archive", FilePath)], - outputs: [Port("binary", FilePath)], - body: Opaque(ShellCommand("tar xzf {archive} && mv zstd /usr/local/bin/")) - }, - ], - edges: [ - "download/archive" → "extract/archive", - ], -} -``` - -"Install" is not a separate pattern. It's upsert's Create node opened -into a sub-DAG. Download causes extract. The archive flows between them. -Same type all the way down. - ---- - -## Example 2: `tool/tectonic` — Tool With Dependencies - -### At L3 - -``` -Dag { - nodes: [ - Node { id: "tool/zstd", ..., outputs: [Port("binary", ResolvedHandle)] }, - Node { id: "tool/tectonic", ..., inputs: [Port("zstd", ResolvedHandle)], - outputs: [Port("binary", ResolvedHandle)] }, - ], - edges: [ - "zstd/binary" → "tectonic/zstd", // tectonic depends on zstd - ], -} -``` - -The dependency between tools is the same kind of edge as the dependency -between operations within a tool, and the same kind of edge as the -dependency between blocks within an operation. **One edge type.** - -### Open "tectonic" → L2 - -Same upsert shape as zstd. Different bindings (different check command, -different install steps). The pattern is reused. The structure is -identical: - -``` -[Check] --[ResourceState]--> [Create] --[ResourceRef]--> [Resolve] -``` - -Each of these nodes can be opened into L1 sub-DAGs. Tectonic's Create -might involve downloading a release binary, verifying a checksum, and -symlinking — three nodes at L1, connected by typed edges. From L2, -Create is one node with output `ResourceRef`. From L1, it's a DAG. - ---- - -## Example 3: `tool/gh` — Mixed Concerns (Upsert + Capabilities) - -### At L3 - -gh is one node: inputs `[os]`, outputs `[binary, auth_token]`. - -Two outputs — the binary itself (from upsert) and the auth capability -(from a separate sub-DAG). - -### Open → L2 - -Two sub-DAGs inside, composed independently: - -``` -Dag { - nodes: [ - // Upsert sub-DAG (install gh) - Node { id: "gh/upsert/check", ... }, - Node { id: "gh/upsert/create", ... }, - Node { id: "gh/upsert/resolve", ... }, - - // Capability: auth - Node { - id: "gh/auth/check-token", - inputs: [], - outputs: [Port("status", AuthStatus)], - body: Opaque(ShellCommand("gh auth status")) - }, - Node { - id: "gh/auth/login", - inputs: [Port("status", AuthStatus, guard: Equals(NotAuthenticated))], - outputs: [Port("token", GuardedOutput)], - body: Opaque(ShellCommand("gh auth login")) - }, - ], - edges: [ - // upsert internal edges - "upsert/check/state" → "upsert/create/state", - "upsert/check/state" → "upsert/resolve/state", - "upsert/create/ref" → "upsert/resolve/ref", - // auth internal edges - "auth/check-token/status" → "auth/login/status", - // cross-concern: auth depends on binary existing - "upsert/resolve/handle" → "auth/check-token/requires_binary", - ], -} -``` - -The auth capability is itself a causal chain — check token status, then -conditionally login. It has the same shape as upsert (check → guarded -create) but it's a different pattern. From L3, both sub-DAGs collapse -into the gh node's two output ports. - -Note the cross-concern edge: auth depends on the binary existing. This -is just another typed edge. No special "composition" mechanism. The DAG -handles it. - ---- - -## Example 4: `gistgen` — Minimal Ontology + Plan - -This example shows how a program is rooted in a small ontology of its -own concepts, while treating external systems as opaque. - -### Ontology (minimal) - -- **Context**: the root observation source (env, args, time). -- **Repo**: the repository identity (e.g., path + optional ref). -- **Snapshot**: the selected files + contents (payload). -- **RepoSnapshot**: the composition of `Repo` + `Snapshot`. -- **SelectionSpec**: rules for which files are included. -- **AuthCapability**: secret token that permits GitHub API calls. -- **Gist**: a GitHub artifact created from a payload of files. -- **GistUrl**: the handle returned by GitHub. -- **Gistgen**: the process that emits a new Gist. - -We do **not** model GitHub's internal set theory. The only external -contract we rely on is: "given payload + auth, GitHub returns a URL." - -### Pattern decisions (total, explicit) - -| Pattern | Tool | Decision | Reason | -|---|---|---|---| -| Upsert | auth | Instantiated | capability resource | -| Upsert | gistgen | NotApplicable | non-idempotent emission | - -### Top-level DAG - -``` -Dag { - nodes: [ - Node { id: "context", outputs: [Env, Args, Time], body: Opaque(Context) }, - Node { id: "auth", inputs: [Env], outputs: [Secret], body: SubDag(Upsert) }, - Node { id: "parse_args", inputs: [Args], outputs: [Repo, SelectionSpec], body: Opaque(ParseArgs) }, - Node { id: "gistgen", - inputs: [Repo, SelectionSpec, Secret], - outputs: [GistUrl], - body: SubDag(...) }, - ], - edges: [ - "context/env" → "auth/env", - "context/args" → "parse_args/args", - "parse_args/repo" → "gistgen/repo", - "parse_args/selection_spec"→ "gistgen/selection_spec", - "auth/token" → "gistgen/token", - ], -} -``` - -`gistgen` is a **WriteWorld + NonIdempotent** emission. It does not -pretend to be an upsert. That is the explicit modeling choice. - -### Open `gistgen` → Sub-DAG - -``` -enumerate_files → filter_files → read_files → compose_snapshot → upload_gist -``` - -Behavior metadata (minimal): -- `enumerate_files`: Observe (reads repo) -- `filter_files`: Pure (no I/O) -- `read_files`: Observe (reads disk) -- `compose_snapshot`: Pure (Repo + Snapshot → RepoSnapshot) -- `upload_gist`: WritesWorld + NonIdempotent - -This gives a concrete, ontology-rooted program without importing any -external semantics beyond "payload in → URL out." - ---- - -## What Disappeared - -| V1/V2 concept | V3 representation | -|---|---| -| `Understanding` struct | `Node` with `SubDag` body | -| `Behavior` struct | `Node` inside a sub-DAG | -| `Block` struct | `Node` — leaf or sub-DAG | -| `behaviors: &[Behavior]` (flat list) | `Dag { nodes, edges }` (causal graph) | -| `upsert_phase: Option` | Node position in the upsert sub-DAG | -| `depends_on: &[Dependency]` | Typed edges in the L3 DAG | -| `to_blocks()` (skip L2) | Recursive flattening — every level is traversed | -| GraphIR (separate type) | `Dag` — same type, all nodes opaque | -| `PatternUse` / `NotApplicable` | Sub-DAG body present or node is opaque — same enum | -| `CompositionSpec` | Just edges between nodes in the same DAG | - -The entire composition/pattern/behavior vocabulary reduces to: **nodes, -edges, sub-DAGs, and opaque leaves.** Everything else was scaffolding -for a missing recursive type. - ---- - -## The Key Observation - -In V1, the tool definition and the execution graph were different types -with a lossy bridge (`to_blocks()`). In V3, they're the same type at -different zoom levels. There's no bridge because there's no gap. You -just keep opening nodes until you hit opaque leaves, then you execute. - -The "levels" aren't architectural layers. They're how deep you've -recursed into `NodeBody::SubDag`. The system doesn't know about levels. -It knows about nodes. diff --git a/viz-data.json b/viz-data.json new file mode 100644 index 00000000000..e77058c643c --- /dev/null +++ b/viz-data.json @@ -0,0 +1,1320 @@ +{ + "graphs": [ + { + "id": "gunbc-gist", + "name": "gunbc-gist", + "nodes": [ + { + "id": "list_files", + "label": "list_files", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "repo_path", + "type": "String", + "is_entrypoint": true, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "files", + "type": "StrList", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": false, + "is_entrypoint": true, + "classes": [ + "entrypoint" + ] + }, + { + "id": "filter_files", + "label": "filter_files", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "files", + "type": "StrList", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "files", + "type": "StrList", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": false, + "is_entrypoint": false, + "classes": [] + }, + { + "id": "read_files", + "label": "read_files", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "files", + "type": "StrList", + "is_entrypoint": false, + "is_boundary": false + }, + { + "name": "repo_path", + "type": "String", + "is_entrypoint": true, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "contents", + "type": "MapStrStr", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": false, + "is_entrypoint": true, + "classes": [ + "entrypoint" + ] + }, + { + "id": "render_markdown", + "label": "render_markdown", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "contents", + "type": "MapStrStr", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "markdown", + "type": "String", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": false, + "is_entrypoint": false, + "classes": [] + }, + { + "id": "prepare_gist_request", + "label": "prepare_gist_request", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "markdown", + "type": "String", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "request", + "type": "TransportRequest", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": false, + "is_entrypoint": false, + "classes": [] + }, + { + "id": "execute_transport", + "label": "execute_transport", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "request", + "type": "TransportRequest", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "response", + "type": "TransportResponse", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "url", + "type": "String", + "is_entrypoint": false, + "is_boundary": true + } + ], + "is_boundary": true, + "is_entrypoint": false, + "classes": [ + "boundary" + ] + } + ], + "edges": [ + { + "id": "e0", + "source": "list_files", + "target": "filter_files", + "source_port": "files", + "target_port": "files", + "data_type": "StrList" + }, + { + "id": "e1", + "source": "filter_files", + "target": "read_files", + "source_port": "files", + "target_port": "files", + "data_type": "StrList" + }, + { + "id": "e2", + "source": "read_files", + "target": "render_markdown", + "source_port": "contents", + "target_port": "contents", + "data_type": "MapStrStr" + }, + { + "id": "e3", + "source": "render_markdown", + "target": "prepare_gist_request", + "source_port": "markdown", + "target_port": "markdown", + "data_type": "String" + }, + { + "id": "e4", + "source": "prepare_gist_request", + "target": "execute_transport", + "source_port": "request", + "target_port": "request", + "data_type": "TransportRequest" + } + ], + "meta": { + "node_count": 6, + "edge_count": 5, + "boundary_count": 1, + "entrypoint_count": 2, + "max_depth": 0 + } + }, + { + "id": "gunbc-buck2", + "name": "gunbc-buck2", + "nodes": [ + { + "id": "parse_cargo_toml", + "label": "parse_cargo_toml", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "cargo_toml_path", + "type": "String", + "is_entrypoint": true, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "cargo_toml", + "type": "Json", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": false, + "is_entrypoint": true, + "classes": [ + "entrypoint" + ] + }, + { + "id": "extract_deps", + "label": "extract_deps", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "cargo_toml", + "type": "Json", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "members", + "type": "StrList", + "is_entrypoint": false, + "is_boundary": false + }, + { + "name": "deps", + "type": "MapStrStr", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": false, + "is_entrypoint": false, + "classes": [] + }, + { + "id": "generate_targets", + "label": "generate_targets", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "members", + "type": "StrList", + "is_entrypoint": false, + "is_boundary": false + }, + { + "name": "deps", + "type": "MapStrStr", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "buck_content", + "type": "String", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": false, + "is_entrypoint": false, + "classes": [] + }, + { + "id": "prepare_file_write", + "label": "prepare_file_write", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "buck_content", + "type": "String", + "is_entrypoint": false, + "is_boundary": false + }, + { + "name": "output_path", + "type": "String", + "is_entrypoint": true, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "request", + "type": "TransportRequest", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": false, + "is_entrypoint": true, + "classes": [ + "entrypoint" + ] + }, + { + "id": "execute_transport", + "label": "execute_transport", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "request", + "type": "TransportRequest", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "response", + "type": "TransportResponse", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "written_path", + "type": "String", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "content", + "type": "String", + "is_entrypoint": false, + "is_boundary": true + } + ], + "is_boundary": true, + "is_entrypoint": false, + "classes": [ + "boundary" + ] + } + ], + "edges": [ + { + "id": "e0", + "source": "parse_cargo_toml", + "target": "extract_deps", + "source_port": "cargo_toml", + "target_port": "cargo_toml", + "data_type": "Json" + }, + { + "id": "e1", + "source": "extract_deps", + "target": "generate_targets", + "source_port": "members", + "target_port": "members", + "data_type": "StrList" + }, + { + "id": "e2", + "source": "extract_deps", + "target": "generate_targets", + "source_port": "deps", + "target_port": "deps", + "data_type": "MapStrStr" + }, + { + "id": "e3", + "source": "generate_targets", + "target": "prepare_file_write", + "source_port": "buck_content", + "target_port": "buck_content", + "data_type": "String" + }, + { + "id": "e4", + "source": "prepare_file_write", + "target": "execute_transport", + "source_port": "request", + "target_port": "request", + "data_type": "TransportRequest" + } + ], + "meta": { + "node_count": 5, + "edge_count": 5, + "boundary_count": 1, + "entrypoint_count": 2, + "max_depth": 0 + } + }, + { + "id": "gunbc-makegen", + "name": "gunbc-makegen", + "nodes": [ + { + "id": "load_registry", + "label": "load_registry", + "parent": null, + "type": "opaque", + "inputs": [], + "outputs": [ + { + "name": "tool_count", + "type": "Int", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "tool_names", + "type": "StrList", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "registry", + "type": "Json", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": true, + "is_entrypoint": false, + "classes": [ + "boundary" + ] + }, + { + "id": "render_makefile", + "label": "render_makefile", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "registry", + "type": "Json", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "makefile_content", + "type": "String", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": false, + "is_entrypoint": false, + "classes": [] + }, + { + "id": "write_makefile", + "label": "write_makefile", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "makefile_content", + "type": "String", + "is_entrypoint": false, + "is_boundary": false + }, + { + "name": "output_path", + "type": "String", + "is_entrypoint": true, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "written_path", + "type": "String", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "content", + "type": "String", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "changed", + "type": "Bool", + "is_entrypoint": false, + "is_boundary": true + } + ], + "is_boundary": true, + "is_entrypoint": true, + "classes": [ + "boundary", + "entrypoint" + ] + } + ], + "edges": [ + { + "id": "e0", + "source": "load_registry", + "target": "render_makefile", + "source_port": "registry", + "target_port": "registry", + "data_type": "Json" + }, + { + "id": "e1", + "source": "render_makefile", + "target": "write_makefile", + "source_port": "makefile_content", + "target_port": "makefile_content", + "data_type": "String" + } + ], + "meta": { + "node_count": 3, + "edge_count": 2, + "boundary_count": 2, + "entrypoint_count": 1, + "max_depth": 0 + } + }, + { + "id": "gunbc-deps", + "name": "gunbc-deps", + "nodes": [ + { + "id": "load_manifest", + "label": "load_manifest", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "manifest_path", + "type": "String", + "is_entrypoint": true, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "dep_count", + "type": "Int", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "dep_names", + "type": "StrList", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "manifest_path", + "type": "String", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": true, + "is_entrypoint": true, + "classes": [ + "boundary", + "entrypoint" + ] + }, + { + "id": "generate_scripts", + "label": "generate_scripts", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "manifest_path", + "type": "String", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "install_script", + "type": "String", + "is_entrypoint": false, + "is_boundary": false + }, + { + "name": "already_installed", + "type": "StrList", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "needs_install", + "type": "StrList", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "platform", + "type": "String", + "is_entrypoint": false, + "is_boundary": true + } + ], + "is_boundary": true, + "is_entrypoint": false, + "classes": [ + "boundary" + ] + }, + { + "id": "execute_installs", + "label": "execute_installs", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "install_script", + "type": "String", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "executed", + "type": "Bool", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "script", + "type": "String", + "is_entrypoint": false, + "is_boundary": true + } + ], + "is_boundary": true, + "is_entrypoint": false, + "classes": [ + "boundary" + ] + } + ], + "edges": [ + { + "id": "e0", + "source": "load_manifest", + "target": "generate_scripts", + "source_port": "manifest_path", + "target_port": "manifest_path", + "data_type": "String" + }, + { + "id": "e1", + "source": "generate_scripts", + "target": "execute_installs", + "source_port": "install_script", + "target_port": "install_script", + "data_type": "String" + } + ], + "meta": { + "node_count": 3, + "edge_count": 2, + "boundary_count": 3, + "entrypoint_count": 1, + "max_depth": 0 + } + }, + { + "id": "gunbc-ci", + "name": "gunbc-ci", + "nodes": [ + { + "id": "setup_deps", + "label": "setup_deps", + "parent": null, + "type": "opaque", + "inputs": [], + "outputs": [ + { + "name": "deps_checked", + "type": "Bool", + "is_entrypoint": false, + "is_boundary": false + }, + { + "name": "deps_installed", + "type": "Int", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "message", + "type": "String", + "is_entrypoint": false, + "is_boundary": true + } + ], + "is_boundary": true, + "is_entrypoint": false, + "classes": [ + "boundary" + ] + }, + { + "id": "build", + "label": "build", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "deps_checked", + "type": "Bool", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "build_success", + "type": "Bool", + "is_entrypoint": false, + "is_boundary": false + }, + { + "name": "build_stdout", + "type": "String", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "build_stderr", + "type": "String", + "is_entrypoint": false, + "is_boundary": true + } + ], + "is_boundary": true, + "is_entrypoint": false, + "classes": [ + "boundary" + ] + }, + { + "id": "test", + "label": "test", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "build_success", + "type": "Bool", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "test_success", + "type": "Bool", + "is_entrypoint": false, + "is_boundary": false + }, + { + "name": "test_skipped", + "type": "Bool", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "test_stdout", + "type": "String", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "test_stderr", + "type": "String", + "is_entrypoint": false, + "is_boundary": true + } + ], + "is_boundary": true, + "is_entrypoint": false, + "classes": [ + "boundary" + ] + }, + { + "id": "lint", + "label": "lint", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "build_success", + "type": "Bool", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "lint_success", + "type": "Bool", + "is_entrypoint": false, + "is_boundary": false + }, + { + "name": "lint_skipped", + "type": "Bool", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "lint_stdout", + "type": "String", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "lint_stderr", + "type": "String", + "is_entrypoint": false, + "is_boundary": true + } + ], + "is_boundary": true, + "is_entrypoint": false, + "classes": [ + "boundary" + ] + }, + { + "id": "report", + "label": "report", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "build_success", + "type": "Bool", + "is_entrypoint": false, + "is_boundary": false + }, + { + "name": "test_success", + "type": "Bool", + "is_entrypoint": false, + "is_boundary": false + }, + { + "name": "lint_success", + "type": "Bool", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "overall_success", + "type": "Bool", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "report", + "type": "String", + "is_entrypoint": false, + "is_boundary": true + } + ], + "is_boundary": true, + "is_entrypoint": false, + "classes": [ + "boundary" + ] + } + ], + "edges": [ + { + "id": "e0", + "source": "setup_deps", + "target": "build", + "source_port": "deps_checked", + "target_port": "deps_checked", + "data_type": "Bool" + }, + { + "id": "e1", + "source": "build", + "target": "test", + "source_port": "build_success", + "target_port": "build_success", + "data_type": "Bool" + }, + { + "id": "e2", + "source": "build", + "target": "lint", + "source_port": "build_success", + "target_port": "build_success", + "data_type": "Bool" + }, + { + "id": "e3", + "source": "build", + "target": "report", + "source_port": "build_success", + "target_port": "build_success", + "data_type": "Bool" + }, + { + "id": "e4", + "source": "test", + "target": "report", + "source_port": "test_success", + "target_port": "test_success", + "data_type": "Bool" + }, + { + "id": "e5", + "source": "lint", + "target": "report", + "source_port": "lint_success", + "target_port": "lint_success", + "data_type": "Bool" + } + ], + "meta": { + "node_count": 5, + "edge_count": 6, + "boundary_count": 5, + "entrypoint_count": 0, + "max_depth": 0 + } + }, + { + "id": "gunbc-bootstrap", + "name": "gunbc-bootstrap", + "nodes": [ + { + "id": "scan_workspace", + "label": "scan_workspace", + "parent": null, + "type": "opaque", + "inputs": [], + "outputs": [ + { + "name": "crate_count", + "type": "Int", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "crate_names", + "type": "StrList", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": true, + "is_entrypoint": false, + "classes": [ + "boundary" + ] + }, + { + "id": "generate_makefile", + "label": "generate_makefile", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "crate_names", + "type": "StrList", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "makefile_content", + "type": "String", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": false, + "is_entrypoint": false, + "classes": [] + }, + { + "id": "generate_gitignore", + "label": "generate_gitignore", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "crate_names", + "type": "StrList", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "gitignore_content", + "type": "String", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": false, + "is_entrypoint": false, + "classes": [] + }, + { + "id": "write_files", + "label": "write_files", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "makefile_content", + "type": "String", + "is_entrypoint": false, + "is_boundary": false + }, + { + "name": "gitignore_content", + "type": "String", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "files_written", + "type": "StrList", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "write_count", + "type": "Int", + "is_entrypoint": false, + "is_boundary": true + } + ], + "is_boundary": true, + "is_entrypoint": false, + "classes": [ + "boundary" + ] + } + ], + "edges": [ + { + "id": "e0", + "source": "scan_workspace", + "target": "generate_makefile", + "source_port": "crate_names", + "target_port": "crate_names", + "data_type": "StrList" + }, + { + "id": "e1", + "source": "scan_workspace", + "target": "generate_gitignore", + "source_port": "crate_names", + "target_port": "crate_names", + "data_type": "StrList" + }, + { + "id": "e2", + "source": "generate_makefile", + "target": "write_files", + "source_port": "makefile_content", + "target_port": "makefile_content", + "data_type": "String" + }, + { + "id": "e3", + "source": "generate_gitignore", + "target": "write_files", + "source_port": "gitignore_content", + "target_port": "gitignore_content", + "data_type": "String" + } + ], + "meta": { + "node_count": 4, + "edge_count": 4, + "boundary_count": 2, + "entrypoint_count": 0, + "max_depth": 0 + } + }, + { + "id": "gunbc-viz", + "name": "gunbc-viz", + "nodes": [ + { + "id": "collect_dags", + "label": "collect_dags", + "parent": null, + "type": "opaque", + "inputs": [], + "outputs": [ + { + "name": "graph_count", + "type": "Int", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "graph_names", + "type": "StrList", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "graphs", + "type": "Json", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": true, + "is_entrypoint": false, + "classes": [ + "boundary" + ] + }, + { + "id": "export_json", + "label": "export_json", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "graphs", + "type": "Json", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "json_content", + "type": "String", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": false, + "is_entrypoint": false, + "classes": [] + }, + { + "id": "prepare_file_write", + "label": "prepare_file_write", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "json_content", + "type": "String", + "is_entrypoint": false, + "is_boundary": false + }, + { + "name": "output_path", + "type": "String", + "is_entrypoint": true, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "request", + "type": "TransportRequest", + "is_entrypoint": false, + "is_boundary": false + } + ], + "is_boundary": false, + "is_entrypoint": true, + "classes": [ + "entrypoint" + ] + }, + { + "id": "execute_transport", + "label": "execute_transport", + "parent": null, + "type": "opaque", + "inputs": [ + { + "name": "request", + "type": "TransportRequest", + "is_entrypoint": false, + "is_boundary": false + } + ], + "outputs": [ + { + "name": "response", + "type": "TransportResponse", + "is_entrypoint": false, + "is_boundary": true + }, + { + "name": "written_path", + "type": "String", + "is_entrypoint": false, + "is_boundary": true + } + ], + "is_boundary": true, + "is_entrypoint": false, + "classes": [ + "boundary" + ] + } + ], + "edges": [ + { + "id": "e0", + "source": "collect_dags", + "target": "export_json", + "source_port": "graphs", + "target_port": "graphs", + "data_type": "Json" + }, + { + "id": "e1", + "source": "export_json", + "target": "prepare_file_write", + "source_port": "json_content", + "target_port": "json_content", + "data_type": "String" + }, + { + "id": "e2", + "source": "prepare_file_write", + "target": "execute_transport", + "source_port": "request", + "target_port": "request", + "data_type": "TransportRequest" + } + ], + "meta": { + "node_count": 4, + "edge_count": 3, + "boundary_count": 2, + "entrypoint_count": 1, + "max_depth": 0 + } + } + ], + "generated_at": "1769649970" +} \ No newline at end of file diff --git a/viz.html b/viz.html new file mode 100644 index 00000000000..36d4fbe560b --- /dev/null +++ b/viz.html @@ -0,0 +1,595 @@ + + + + + + + gunbc DAG Visualizer + + + + + + + +
+ +
+
+
+ Boundary (world write) +
+
+
+ Entrypoint (world read) +
+
+
+ Pure node +
+
+
+
+ - + DAGs +
+
+ - + Nodes +
+
+ - + Edges +
+
+
+ +
+
+ +
+
+ +
+ + + + + \ No newline at end of file