From 779bbf5b9740b53ccf9e1f34058ca151903f8e88 Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 18 Feb 2026 06:55:49 +0100 Subject: [PATCH 01/20] chore: remove dead code folder --- src/main.py | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 src/main.py diff --git a/src/main.py b/src/main.py deleted file mode 100644 index 4e3d766..0000000 --- a/src/main.py +++ /dev/null @@ -1,5 +0,0 @@ -# &begin[test] -def test(): ... - - -# &end[test] From 0485fd440ff0889f8d8283830495127ec585af0f Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 18 Feb 2026 07:10:17 +0100 Subject: [PATCH 02/20] feat: initialize rust setup and cli structure --- .gitignore | 6 +- Cargo.lock | 676 +++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 13 + README.md | 28 ++ src/cli.rs | 14 + src/commands.rs | 1 + src/commands/init.rs | 6 + src/main.rs | 21 ++ 8 files changed, 764 insertions(+), 1 deletion(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/cli.rs create mode 100644 src/commands.rs create mode 100644 src/commands/init.rs create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore index 7bf286f..ee5854e 100644 --- a/.gitignore +++ b/.gitignore @@ -161,4 +161,8 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -.vscode/ \ No newline at end of file +.vscode/ + +# Added by cargo + +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..949d7a9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,676 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "git-varcs" +version = "2.0.0-alpha.0" +dependencies = [ + "anyhow", + "clap", + "git2", +] + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libgit2-sys" +version = "0.18.3+1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..27b37e7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "git-varcs" +version = "2.0.0-alpha.0" +edition = "2024" + +[[bin]] +name = "git-vms" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0.101" +clap = { version = "4.5.59", features = ["derive"] } +git2 = "0.20.4" diff --git a/README.md b/README.md index 4df1e3c..d7a4cc3 100644 --- a/README.md +++ b/README.md @@ -160,3 +160,31 @@ For further details or to explore more usage options, refer to the [Typer Docume ## Development 1. Create a virtual environment and install both requirement-files. + +### Rust Components + +To build and run the rust components, use standard Cargo commands from the project root: + +```bash +# Build the project +cargo build + +# Run the project +cargo run +``` + +The binary will be compiled into `target/debug/git-vms`. + +**Symlinking Binaries** + +For rapid development, you can symlink the compiled binaries to a location in your `$PATH` so you can run them like any other git command: + +```bash +ln -s $(pwd)/target/debug/git-vms /path/to/your/bin/git-vms +``` + +After that, you can run the binary directly: + +```bash +git vms --version +``` diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..43233ff --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,14 @@ +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(version, about)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Initialize variation management support in the git repository + Init, +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..43763f1 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1 @@ +pub mod init; diff --git a/src/commands/init.rs b/src/commands/init.rs new file mode 100644 index 0000000..d2d4b1b --- /dev/null +++ b/src/commands/init.rs @@ -0,0 +1,6 @@ +use anyhow::Result; +use git2::Repository; + +pub fn run(_repo: &Repository) -> Result<()> { + todo!("Initialize fog in the repository"); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2d2f95e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,21 @@ +mod cli; +mod commands; + +use anyhow::{Context, Result}; +use clap::Parser; +use git2::Repository; + +use cli::{Cli, Commands}; +use commands::init; + +fn main() -> Result<()> { + let args = Cli::parse(); + let repo = Repository::discover(".").context("Not in a git repository")?; + + match args.command { + Commands::Init => { + init::run(&repo).context("Failed to initialize varcs support")?; + } + } + Ok(()) +} From 7dfd1fc89f5868caf127ffa7685c9547534eaa0d Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 25 Mar 2026 11:48:15 +0100 Subject: [PATCH 03/20] feat: add variant management support init command --- src/commands/init.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/commands/init.rs b/src/commands/init.rs index d2d4b1b..203b1a1 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -1,6 +1,29 @@ -use anyhow::Result; +use std::fs; +use std::io::Write; + +use anyhow::{Context, Result}; use git2::Repository; -pub fn run(_repo: &Repository) -> Result<()> { - todo!("Initialize fog in the repository"); +const DEFAULT_CONFIG: &str = r#"# git-varcs configuration file +# You can define variants and associate features here +"#; + +pub fn run(repo: &Repository) -> Result<()> { + let base_path = repo + .path() + .parent() + .context("Repository has no parent directory")?; + let config_path = base_path.join("varcs.toml"); + + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&config_path) + .with_context(|| format!("Failed to create config file at path: {:?}", config_path))?; + + file.write(DEFAULT_CONFIG.as_bytes()) + .with_context(|| format!("Failed to write default config to: {:?}", config_path))?; + + println!("Initialized config file at {}", config_path.display()); + Ok(()) } From 1815ce9fe80cc849d972b1bf437037a10af8c869 Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 25 Mar 2026 11:58:09 +0100 Subject: [PATCH 04/20] feat: add command to stage files with feature meta --- src/cli.rs | 8 ++++++++ src/commands.rs | 1 + src/commands/add.rs | 31 +++++++++++++++++++++++++++++++ src/commands/init.rs | 3 +++ src/main.rs | 6 +++++- src/meta.rs | 14 ++++++++++++++ 6 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/commands/add.rs create mode 100644 src/meta.rs diff --git a/src/cli.rs b/src/cli.rs index 43233ff..d0597a8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,4 +11,12 @@ pub struct Cli { pub enum Commands { /// Initialize variation management support in the git repository Init, + /// Associate feature metadata and stage files to the git index + Add { + /// Files to tag with feature meta and stage to git + files: Vec, + /// Feature to associate the file(s) with + #[arg(short, long)] + feature: String, + }, } diff --git a/src/commands.rs b/src/commands.rs index 43763f1..d7bb0c0 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1 +1,2 @@ +pub mod add; pub mod init; diff --git a/src/commands/add.rs b/src/commands/add.rs new file mode 100644 index 0000000..35140dc --- /dev/null +++ b/src/commands/add.rs @@ -0,0 +1,31 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use git2::Repository; + +use crate::meta::{read_meta, write_meta}; + +pub fn run(repo: &Repository, files: &[String], feature: &str) -> Result<()> { + let feature_meta_path = repo.path().join("varcs").join("FEATUREMETA"); + let current_index_feature = read_meta(&feature_meta_path).unwrap_or_default(); + + if current_index_feature.is_empty() { + write_meta(&feature_meta_path, feature)?; + } else if feature != current_index_feature { + eprintln!( + "Current index is already associated with feature '{}'. Commit it before continuing.", + current_index_feature + ); + return Ok(()); + } + + let mut index = repo.index()?; + for file in files { + index + .add_path(Path::new(file)) + .with_context(|| format!("Failed to stage path '{}'", file))?; + } + index.write()?; + + Ok(()) +} diff --git a/src/commands/init.rs b/src/commands/init.rs index 203b1a1..60dc5f1 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -14,6 +14,9 @@ pub fn run(repo: &Repository) -> Result<()> { .parent() .context("Repository has no parent directory")?; let config_path = base_path.join("varcs.toml"); + let meta_path = base_path.join(".git/varcs"); + + fs::create_dir(meta_path)?; let mut file = fs::OpenOptions::new() .write(true) diff --git a/src/main.rs b/src/main.rs index 2d2f95e..6f35d9c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,13 @@ mod cli; mod commands; +mod meta; use anyhow::{Context, Result}; use clap::Parser; use git2::Repository; use cli::{Cli, Commands}; -use commands::init; +use commands::{add, init}; fn main() -> Result<()> { let args = Cli::parse(); @@ -16,6 +17,9 @@ fn main() -> Result<()> { Commands::Init => { init::run(&repo).context("Failed to initialize varcs support")?; } + Commands::Add { files, feature } => { + add::run(&repo, &files, &feature).context("Failed staging files with meta")?; + } } Ok(()) } diff --git a/src/meta.rs b/src/meta.rs new file mode 100644 index 0000000..04f4b8a --- /dev/null +++ b/src/meta.rs @@ -0,0 +1,14 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::Result; + +pub fn read_meta(path: &PathBuf) -> Result { + let data = fs::read_to_string(path)?; + Ok(data) +} + +pub fn write_meta(path: &PathBuf, content: &str) -> Result<()> { + fs::write(path, content)?; + Ok(()) +} From fc03f72dda3596282454a135fac392fee057ed1e Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 25 Mar 2026 12:07:42 +0100 Subject: [PATCH 05/20] feat: command to commit changes with feature meta --- src/cli.rs | 6 ++++++ src/commands.rs | 1 + src/commands/commit.rs | 48 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 5 ++++- 4 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 src/commands/commit.rs diff --git a/src/cli.rs b/src/cli.rs index d0597a8..8e2a63b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -19,4 +19,10 @@ pub enum Commands { #[arg(short, long)] feature: String, }, + /// Commit staged changes with feature metadata + Commit { + /// Commit message + #[arg(short, long)] + message: String, + }, } diff --git a/src/commands.rs b/src/commands.rs index d7bb0c0..a6e1301 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,2 +1,3 @@ pub mod add; +pub mod commit; pub mod init; diff --git a/src/commands/commit.rs b/src/commands/commit.rs new file mode 100644 index 0000000..3de78fc --- /dev/null +++ b/src/commands/commit.rs @@ -0,0 +1,48 @@ +use anyhow::{Context, Result}; +use git2::Repository; + +use crate::meta::{read_meta, write_meta}; + +pub fn run(repo: &Repository, message: &str) -> Result<()> { + let feature_meta_path = repo.path().join("varcs").join("FEATUREMETA"); + let current_index_feature = read_meta(&feature_meta_path).unwrap_or_default(); + + if current_index_feature.is_empty() { + eprintln!("No associated feature metadata. Aborting."); + return Ok(()); + } + + let mut index = repo.index()?; + + let tree_oid = index + .write_tree() + .context("failed to write tree from index")?; + let tree = repo.find_tree(tree_oid)?; + + let sig = repo + .signature() + .context("failed to determine git signature")?; + index.write()?; + + let parent_commit = match repo.head() { + Ok(head) => { + let commit = head + .peel_to_commit() + .context("HEAD does not point to a commit")?; + Some(commit) + } + Err(_) => None, + }; + + let meta_msg = format!( + "{}\n\nCHANGE_ID: {}\nFEATURE: {}\n", + message, tree_oid, current_index_feature + ); + match parent_commit { + Some(parent) => repo.commit(Some("HEAD"), &sig, &sig, &meta_msg, &tree, &[&parent])?, + None => repo.commit(Some("HEAD"), &sig, &sig, &meta_msg, &tree, &[])?, + }; + + write_meta(&feature_meta_path, "")?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 6f35d9c..c73cc72 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use clap::Parser; use git2::Repository; use cli::{Cli, Commands}; -use commands::{add, init}; +use commands::{add, commit, init}; fn main() -> Result<()> { let args = Cli::parse(); @@ -20,6 +20,9 @@ fn main() -> Result<()> { Commands::Add { files, feature } => { add::run(&repo, &files, &feature).context("Failed staging files with meta")?; } + Commands::Commit { message } => { + commit::run(&repo, &message).context("Failed commiting current index")?; + } } Ok(()) } From 0c9d53f9a28d2fabce0f640a94556d43969f341f Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 25 Mar 2026 12:16:13 +0100 Subject: [PATCH 06/20] feat: add variant derivation functionality --- Cargo.lock | 124 ++++++++++++++++++++++++++++++ Cargo.toml | 3 + src/cli.rs | 5 ++ src/commands.rs | 1 + src/commands/derive.rs | 171 +++++++++++++++++++++++++++++++++++++++++ src/config.rs | 28 +++++++ src/main.rs | 7 +- 7 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 src/commands/derive.rs create mode 100644 src/config.rs diff --git a/Cargo.lock b/Cargo.lock index 949d7a9..236e5b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.21" @@ -139,6 +148,12 @@ dependencies = [ "syn", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -173,6 +188,9 @@ dependencies = [ "anyhow", "clap", "git2", + "regex", + "serde", + "toml", ] [[package]] @@ -190,6 +208,12 @@ dependencies = [ "url", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "heck" version = "0.5.0" @@ -298,6 +322,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -372,6 +406,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -441,6 +481,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "serde" version = "1.0.228" @@ -448,6 +517,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -470,6 +540,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "1.3.0" @@ -526,6 +605,45 @@ dependencies = [ "zerovec", ] +[[package]] +name = "toml" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -586,6 +704,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 27b37e7..f67235a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,6 @@ path = "src/main.rs" anyhow = "1.0.101" clap = { version = "4.5.59", features = ["derive"] } git2 = "0.20.4" +regex = "1.12.3" +serde = { version = "1.0.228", features = ["derive"] } +toml = "1.0.7" diff --git a/src/cli.rs b/src/cli.rs index 8e2a63b..fc45b52 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -25,4 +25,9 @@ pub enum Commands { #[arg(short, long)] message: String, }, + /// Derive a variant with the features specified in fog.toml + Derive { + /// Name of the variant entry in fog.toml + name: String, + }, } diff --git a/src/commands.rs b/src/commands.rs index a6e1301..1e70c70 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,3 +1,4 @@ pub mod add; pub mod commit; +pub mod derive; pub mod init; diff --git a/src/commands/derive.rs b/src/commands/derive.rs new file mode 100644 index 0000000..0b1fc15 --- /dev/null +++ b/src/commands/derive.rs @@ -0,0 +1,171 @@ +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow}; +use git2::{BlameOptions, Oid, Repository, Signature, Status, TreeWalkResult}; +use regex::Regex; + +use crate::config::{Variant, read_config}; + +pub fn run(repo: &Repository, name: &str) -> Result<()> { + let statuses = repo.statuses(None)?; + for entry in statuses.iter() { + let s = entry.status(); + if s != Status::CURRENT { + println!("Repository is not clean!"); + return Ok(()); + } + } + + let variant_spec = get_variant_spec(repo, name).context("Failed to get variant spec")?; + let target_features: HashSet = variant_spec.features.iter().cloned().collect(); + let ref_name = format!("refs/heads/variant/{}", variant_spec.name); + let sig = repo.signature()?; + let variant_head_oid = create_variant_initial_commit(repo, &ref_name, &sig)?; + + let head = repo.head()?; + let tree = head.peel_to_tree()?; + + let mut tree_builder = repo.treebuilder(None)?; + + tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { + if let Some(name) = entry.name() { + let path = Path::new(root).join(name); + let content = process_file(repo, &path, &target_features).unwrap(); + + let oid = repo.blob(content.as_bytes()).unwrap(); + tree_builder.insert(path, oid, 0o100644).unwrap(); + } + TreeWalkResult::Ok + })?; + + let variant_tree_oid = tree_builder.write()?; + let variant_tree = repo.find_tree(variant_tree_oid)?; + let variant_parent = repo.find_commit(variant_head_oid)?; + + repo.commit( + Some(&ref_name), + &sig, + &sig, + &format!( + "variant derivation with features {:?}", + variant_spec.features + ), + &variant_tree, + &[&variant_parent], + )?; + Ok(()) +} + +fn get_variant_spec(repo: &Repository, name: &str) -> Result { + let config = read_config(repo)?; + let variant = config + .variants + .get(name) + .ok_or_else(|| anyhow!("no variant found with name '{}'", name))?; + Ok(variant.clone()) +} + +fn create_variant_initial_commit( + repo: &Repository, + ref_name: &str, + sig: &Signature, +) -> Result { + let tree_oid = repo + .treebuilder(None)? + .write() + .context("Failed to create empty tree.")?; + let tree = repo.find_tree(tree_oid)?; + + let variant_head_oid = repo + .commit( + Some(ref_name), + sig, + sig, + "Variant initial commit\n", + &tree, + &[], + ) + .context("Failed to create initial commit for variant")?; + Ok(variant_head_oid) +} + +fn process_file( + repo: &Repository, + name: &PathBuf, + target_features: &HashSet, +) -> Result { + let file = File::open(name)?; + let reader = BufReader::new(file); + let mut lines: Vec = reader.lines().collect::>()?; + + let mut commit_cache: HashMap = HashMap::new(); + + let mut blame_opts = BlameOptions::new(); + let blame = repo.blame_file(Path::new(name), Some(&mut blame_opts))?; + + for (i, line) in lines.iter_mut().enumerate() { + let line_no = i + 1; + + let hunk = blame + .get_line(line_no) + .context("Cant get line info in hunk")?; + + let oid = hunk.final_commit_id(); + + let contains_feature = commit_cache.entry(oid).or_insert_with(|| { + let commit = repo.find_commit(oid).unwrap(); + let summary = commit.body().unwrap(); + extract_feature_meta(summary).is_some_and(|feature| target_features.contains(feature)) + }); + + if !*contains_feature { + *line = String::new(); + } + } + + // lines.retain(|line| !line.is_empty()); + Ok(lines.join("\n")) +} + +// TODO: building each time can be expensive. Look for an alternative +fn extract_feature_meta(commit: &str) -> Option<&str> { + let re = Regex::new(r"(?m)^FEATURE:\s*(?P[^\n]+)$").unwrap(); + re.captures(commit) + .and_then(|caps| caps.name("feature").map(|m| m.as_str())) +} + +#[cfg(test)] +mod tests { + use super::extract_feature_meta; + + #[test] + fn extracts_feature_when_present() { + let msg = "chore: init\n\nFeature: core"; + assert_eq!(extract_feature_meta(msg), Some("core")); + } + + #[test] + fn extracts_feature_with_other_metadata() { + let msg = "\ +chore: initialize fog support in repo + +ChangeId: de9378ec963973b367aa0ad706fa006af3ce65da +Feature: core"; + assert_eq!(extract_feature_meta(msg), Some("core")); + } + + #[test] + fn returns_none_when_no_feature() { + let msg = "fix: missing semicolon"; + assert_eq!(extract_feature_meta(msg), None); + } + + #[test] + fn ignores_invalid_format() { + let msg = "Feature core"; + assert_eq!(extract_feature_meta(msg), None); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..bf0b62b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,28 @@ +use std::collections::HashMap; +use std::fs; + +use anyhow::{Context, Result}; +use git2::Repository; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct Config { + pub variants: HashMap, +} + +#[derive(Deserialize, Clone)] +pub struct Variant { + pub name: String, + pub features: Vec, +} + +pub fn read_config(repo: &Repository) -> Result { + let base_path = repo + .path() + .parent() + .context("Repository has no parent directory")?; + let config_path = base_path.join("varcs.toml"); + let data = fs::read_to_string(config_path)?; + let config = toml::from_str(&data)?; + Ok(config) +} diff --git a/src/main.rs b/src/main.rs index c73cc72..e82f34a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod cli; mod commands; +mod config; mod meta; use anyhow::{Context, Result}; @@ -7,7 +8,7 @@ use clap::Parser; use git2::Repository; use cli::{Cli, Commands}; -use commands::{add, commit, init}; +use commands::{add, commit, derive, init}; fn main() -> Result<()> { let args = Cli::parse(); @@ -23,6 +24,10 @@ fn main() -> Result<()> { Commands::Commit { message } => { commit::run(&repo, &message).context("Failed commiting current index")?; } + Commands::Derive { name } => { + derive::run(&repo, &name) + .with_context(|| format!("Failed to derive variant '{}'", name))?; + } } Ok(()) } From 9c0ca57703266d57e54855f1bfc00f9a8232c83e Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 25 Mar 2026 14:08:51 +0100 Subject: [PATCH 07/20] refactor(derive): remove unnecessary initial commit creation --- src/commands/derive.rs | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/src/commands/derive.rs b/src/commands/derive.rs index 0b1fc15..763258b 100644 --- a/src/commands/derive.rs +++ b/src/commands/derive.rs @@ -23,7 +23,6 @@ pub fn run(repo: &Repository, name: &str) -> Result<()> { let target_features: HashSet = variant_spec.features.iter().cloned().collect(); let ref_name = format!("refs/heads/variant/{}", variant_spec.name); let sig = repo.signature()?; - let variant_head_oid = create_variant_initial_commit(repo, &ref_name, &sig)?; let head = repo.head()?; let tree = head.peel_to_tree()?; @@ -43,7 +42,6 @@ pub fn run(repo: &Repository, name: &str) -> Result<()> { let variant_tree_oid = tree_builder.write()?; let variant_tree = repo.find_tree(variant_tree_oid)?; - let variant_parent = repo.find_commit(variant_head_oid)?; repo.commit( Some(&ref_name), @@ -54,7 +52,7 @@ pub fn run(repo: &Repository, name: &str) -> Result<()> { variant_spec.features ), &variant_tree, - &[&variant_parent], + &[], )?; Ok(()) } @@ -68,30 +66,6 @@ fn get_variant_spec(repo: &Repository, name: &str) -> Result { Ok(variant.clone()) } -fn create_variant_initial_commit( - repo: &Repository, - ref_name: &str, - sig: &Signature, -) -> Result { - let tree_oid = repo - .treebuilder(None)? - .write() - .context("Failed to create empty tree.")?; - let tree = repo.find_tree(tree_oid)?; - - let variant_head_oid = repo - .commit( - Some(ref_name), - sig, - sig, - "Variant initial commit\n", - &tree, - &[], - ) - .context("Failed to create initial commit for variant")?; - Ok(variant_head_oid) -} - fn process_file( repo: &Repository, name: &PathBuf, From 33d5baaf0bfca8637689bfb1ef27db722e6564ba Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 25 Mar 2026 14:36:26 +0100 Subject: [PATCH 08/20] feat(derive): add refresh option for variant generation --- src/cli.rs | 3 +++ src/commands/derive.rs | 23 +++++++++++++++++++---- src/main.rs | 4 ++-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index fc45b52..ca6c482 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -29,5 +29,8 @@ pub enum Commands { Derive { /// Name of the variant entry in fog.toml name: String, + /// Refresh the variant if it already exists + #[arg(short, long)] + refresh: bool, }, } diff --git a/src/commands/derive.rs b/src/commands/derive.rs index 763258b..3179573 100644 --- a/src/commands/derive.rs +++ b/src/commands/derive.rs @@ -4,12 +4,12 @@ use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; -use git2::{BlameOptions, Oid, Repository, Signature, Status, TreeWalkResult}; +use git2::{BlameOptions, Commit, Oid, Repository, Status, TreeWalkResult}; use regex::Regex; use crate::config::{Variant, read_config}; -pub fn run(repo: &Repository, name: &str) -> Result<()> { +pub fn run(repo: &Repository, name: &str, refresh: bool) -> Result<()> { let statuses = repo.statuses(None)?; for entry in statuses.iter() { let s = entry.status(); @@ -20,8 +20,23 @@ pub fn run(repo: &Repository, name: &str) -> Result<()> { } let variant_spec = get_variant_spec(repo, name).context("Failed to get variant spec")?; - let target_features: HashSet = variant_spec.features.iter().cloned().collect(); let ref_name = format!("refs/heads/variant/{}", variant_spec.name); + + let variant_ref = repo.find_reference(&ref_name).ok(); + + let parent_refs = if let Some(variant_ref) = variant_ref { + if refresh { + let commit = variant_ref.peel_to_commit()?; + vec![commit] + } else { + anyhow::bail!("Variant already exists. Use --refresh to update it."); + } + } else { + vec![] + }; + let parent_refs: Vec<&Commit> = parent_refs.iter().collect(); + + let target_features: HashSet = variant_spec.features.iter().cloned().collect(); let sig = repo.signature()?; let head = repo.head()?; @@ -52,7 +67,7 @@ pub fn run(repo: &Repository, name: &str) -> Result<()> { variant_spec.features ), &variant_tree, - &[], + &parent_refs, )?; Ok(()) } diff --git a/src/main.rs b/src/main.rs index e82f34a..12fa3bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,8 +24,8 @@ fn main() -> Result<()> { Commands::Commit { message } => { commit::run(&repo, &message).context("Failed commiting current index")?; } - Commands::Derive { name } => { - derive::run(&repo, &name) + Commands::Derive { name, refresh } => { + derive::run(&repo, &name, refresh) .with_context(|| format!("Failed to derive variant '{}'", name))?; } } From 410d8987681cafc3abe31594fa24065826898be2 Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 25 Mar 2026 14:54:40 +0100 Subject: [PATCH 09/20] fix(derive): preserve filemode instead of a generic hardcoded value --- src/commands/derive.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/derive.rs b/src/commands/derive.rs index 3179573..7694aff 100644 --- a/src/commands/derive.rs +++ b/src/commands/derive.rs @@ -50,7 +50,7 @@ pub fn run(repo: &Repository, name: &str, refresh: bool) -> Result<()> { let content = process_file(repo, &path, &target_features).unwrap(); let oid = repo.blob(content.as_bytes()).unwrap(); - tree_builder.insert(path, oid, 0o100644).unwrap(); + tree_builder.insert(path, oid, entry.filemode()).unwrap(); } TreeWalkResult::Ok })?; From f845e5c06b6ee21930c55ae6098d21a4a617c7fc Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 25 Mar 2026 15:15:35 +0100 Subject: [PATCH 10/20] feat(derive): handle nested directories when deriving variants --- src/commands/derive.rs | 52 ++++++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/src/commands/derive.rs b/src/commands/derive.rs index 7694aff..ce83491 100644 --- a/src/commands/derive.rs +++ b/src/commands/derive.rs @@ -4,7 +4,7 @@ use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; -use git2::{BlameOptions, Commit, Oid, Repository, Status, TreeWalkResult}; +use git2::{BlameOptions, Commit, ObjectType, Oid, Repository, Status, Tree}; use regex::Regex; use crate::config::{Variant, read_config}; @@ -42,20 +42,7 @@ pub fn run(repo: &Repository, name: &str, refresh: bool) -> Result<()> { let head = repo.head()?; let tree = head.peel_to_tree()?; - let mut tree_builder = repo.treebuilder(None)?; - - tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { - if let Some(name) = entry.name() { - let path = Path::new(root).join(name); - let content = process_file(repo, &path, &target_features).unwrap(); - - let oid = repo.blob(content.as_bytes()).unwrap(); - tree_builder.insert(path, oid, entry.filemode()).unwrap(); - } - TreeWalkResult::Ok - })?; - - let variant_tree_oid = tree_builder.write()?; + let variant_tree_oid = build_variant_tree(repo, &tree, Path::new(""), &target_features)?; let variant_tree = repo.find_tree(variant_tree_oid)?; repo.commit( @@ -81,6 +68,41 @@ fn get_variant_spec(repo: &Repository, name: &str) -> Result { Ok(variant.clone()) } +fn build_variant_tree( + repo: &Repository, + tree: &Tree, + base_path: &Path, + target_features: &HashSet, +) -> Result { + let mut builder = repo.treebuilder(None)?; + for entry in tree.iter() { + let name = entry + .name() + .ok_or_else(|| anyhow!("tree entry without valid UTF-8 name"))?; + let full_path = base_path.join(name); + + match entry.kind() { + Some(ObjectType::Blob) => { + let content = process_file(repo, &full_path, target_features) + .with_context(|| format!("Failed to process file {}", full_path.display()))?; + let oid = repo.blob(content.as_bytes())?; + builder.insert(name, oid, entry.filemode())?; + } + Some(ObjectType::Tree) => { + let subtree = repo.find_tree(entry.id())?; + let subtree_oid = build_variant_tree(repo, &subtree, &full_path, target_features) + .with_context(|| { + format!("Failed to process directory: {}", full_path.display()) + })?; + builder.insert(name, subtree_oid, entry.filemode())?; + } + _ => {} + } + } + + Ok(builder.write()?) +} + fn process_file( repo: &Repository, name: &PathBuf, From 5437234a6e3589cd71bd2f6ed6cd50d45c2e1127 Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 22 Apr 2026 08:48:16 +0200 Subject: [PATCH 11/20] feat(cli)!: rename devire command to checkout --- src/cli.rs | 4 ++-- src/commands.rs | 2 +- src/commands/{derive.rs => checkout.rs} | 0 src/main.rs | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) rename src/commands/{derive.rs => checkout.rs} (100%) diff --git a/src/cli.rs b/src/cli.rs index ca6c482..ef1bab5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -25,8 +25,8 @@ pub enum Commands { #[arg(short, long)] message: String, }, - /// Derive a variant with the features specified in fog.toml - Derive { + /// Checkout a variant with the features specified in fog.toml + Checkout { /// Name of the variant entry in fog.toml name: String, /// Refresh the variant if it already exists diff --git a/src/commands.rs b/src/commands.rs index 1e70c70..6a5cfdb 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,4 +1,4 @@ pub mod add; +pub mod checkout; pub mod commit; -pub mod derive; pub mod init; diff --git a/src/commands/derive.rs b/src/commands/checkout.rs similarity index 100% rename from src/commands/derive.rs rename to src/commands/checkout.rs diff --git a/src/main.rs b/src/main.rs index 12fa3bb..20b47f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use clap::Parser; use git2::Repository; use cli::{Cli, Commands}; -use commands::{add, commit, derive, init}; +use commands::{add, checkout, commit, init}; fn main() -> Result<()> { let args = Cli::parse(); @@ -24,8 +24,8 @@ fn main() -> Result<()> { Commands::Commit { message } => { commit::run(&repo, &message).context("Failed commiting current index")?; } - Commands::Derive { name, refresh } => { - derive::run(&repo, &name, refresh) + Commands::Checkout { name, refresh } => { + checkout::run(&repo, &name, refresh) .with_context(|| format!("Failed to derive variant '{}'", name))?; } } From ae27ea6bf119aeded5674b67c60e94141835db5f Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 22 Apr 2026 09:07:46 +0200 Subject: [PATCH 12/20] feat(checkout): automatically switch to the variant branch --- src/commands/checkout.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/commands/checkout.rs b/src/commands/checkout.rs index ce83491..f27e672 100644 --- a/src/commands/checkout.rs +++ b/src/commands/checkout.rs @@ -56,6 +56,10 @@ pub fn run(repo: &Repository, name: &str, refresh: bool) -> Result<()> { &variant_tree, &parent_refs, )?; + + // Switch to the derived variant branch + repo.set_head(&ref_name)?; + repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?; Ok(()) } From 761f6fff278563e8f3045a2c445a852a79fedd75 Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 22 Apr 2026 11:11:30 +0200 Subject: [PATCH 13/20] feat(checkout): add feature boundary markers in variant --- src/commands/checkout.rs | 53 ++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/src/commands/checkout.rs b/src/commands/checkout.rs index f27e672..4049911 100644 --- a/src/commands/checkout.rs +++ b/src/commands/checkout.rs @@ -115,8 +115,12 @@ fn process_file( let file = File::open(name)?; let reader = BufReader::new(file); let mut lines: Vec = reader.lines().collect::>()?; + let mut output: Vec = vec![]; - let mut commit_cache: HashMap = HashMap::new(); + let mut hidden_start: Option = Option::None; + let mut current_hidden_feature: Option = None; + + let mut commit_cache: HashMap> = HashMap::new(); let mut blame_opts = BlameOptions::new(); let blame = repo.blame_file(Path::new(name), Some(&mut blame_opts))?; @@ -130,19 +134,54 @@ fn process_file( let oid = hunk.final_commit_id(); - let contains_feature = commit_cache.entry(oid).or_insert_with(|| { + let line_feature = commit_cache.entry(oid).or_insert_with(|| { let commit = repo.find_commit(oid).unwrap(); let summary = commit.body().unwrap(); - extract_feature_meta(summary).is_some_and(|feature| target_features.contains(feature)) + extract_feature_meta(summary).map(|s| s.to_string()) }); - if !*contains_feature { - *line = String::new(); + let is_visible = match line_feature { + Some(f) => target_features.contains(f), + None => true, + }; + + if is_visible { + if let (Some(start), Some(feature)) = (hidden_start, current_hidden_feature.take()) { + output.push(format!( + "# morph:{}-{} hidden feature '{}'", + start, + line_no - 1, + feature + )); + hidden_start = None; + } + output.push(line.clone()); + } else if hidden_start.is_none() { + hidden_start = Some(line_no); + current_hidden_feature = line_feature.clone(); + } else if current_hidden_feature.as_ref() != line_feature.as_ref() + && let Some(feature) = current_hidden_feature.take() + { + output.push(format!( + "// morph:{}-{} hidden feature '{}'", + hidden_start.unwrap(), + line_no, + feature + )); + hidden_start = Some(line_no); + current_hidden_feature = line_feature.clone(); } } - // lines.retain(|line| !line.is_empty()); - Ok(lines.join("\n")) + if let (Some(start), Some(feature)) = (hidden_start, current_hidden_feature) { + output.push(format!( + "# morph:{}-{} hidden feature '{}'", + start, + lines.len(), + feature + )); + } + Ok(output.join("\n")) } // TODO: building each time can be expensive. Look for an alternative From 432ae775c4ec3a52aff513d4eccd56e02fb0d9bf Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 22 Apr 2026 14:53:21 +0200 Subject: [PATCH 14/20] feat(checkout): store derivation origin meta for reconstruction --- Cargo.lock | 220 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 + src/commands/checkout.rs | 70 ++++++++----- src/meta.rs | 34 +++++- 4 files changed, 298 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 236e5b8..b616c04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -67,12 +76,24 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "cc" version = "1.2.56" @@ -91,6 +112,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.59" @@ -137,6 +171,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "displaydoc" version = "0.2.5" @@ -186,10 +226,12 @@ name = "git-varcs" version = "2.0.0-alpha.0" dependencies = [ "anyhow", + "chrono", "clap", "git2", "regex", "serde", + "serde_json", "toml", ] @@ -220,6 +262,30 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -338,6 +404,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jobserver" version = "0.1.34" @@ -348,6 +420,16 @@ dependencies = [ "libc", ] +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.182" @@ -412,6 +494,21 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -510,6 +607,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "serde" version = "1.0.228" @@ -540,6 +643,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "1.1.0" @@ -689,12 +805,110 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -798,3 +1012,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index f67235a..e5c93ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +9,10 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.101" +chrono = "0.4.44" clap = { version = "4.5.59", features = ["derive"] } git2 = "0.20.4" regex = "1.12.3" serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" toml = "1.0.7" diff --git a/src/commands/checkout.rs b/src/commands/checkout.rs index 4049911..dd85f3a 100644 --- a/src/commands/checkout.rs +++ b/src/commands/checkout.rs @@ -4,10 +4,12 @@ use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; +use chrono::Utc; use git2::{BlameOptions, Commit, ObjectType, Oid, Repository, Status, Tree}; use regex::Regex; use crate::config::{Variant, read_config}; +use crate::meta::{VariantMeta, read_variant_meta, write_variant_meta}; pub fn run(repo: &Repository, name: &str, refresh: bool) -> Result<()> { let statuses = repo.statuses(None)?; @@ -40,6 +42,7 @@ pub fn run(repo: &Repository, name: &str, refresh: bool) -> Result<()> { let sig = repo.signature()?; let head = repo.head()?; + let head_commit = head.peel_to_commit()?; let tree = head.peel_to_tree()?; let variant_tree_oid = build_variant_tree(repo, &tree, Path::new(""), &target_features)?; @@ -57,6 +60,17 @@ pub fn run(repo: &Repository, name: &str, refresh: bool) -> Result<()> { &parent_refs, )?; + // Store info about the base state + let mut meta_store = read_variant_meta(repo).unwrap_or_default(); + let meta = VariantMeta { + commit: head_commit.id().to_string(), + tree: tree.id().to_string(), + features: variant_spec.features.clone(), + created_at: Utc::now().to_rfc3339(), + }; + meta_store.variants.insert(variant_spec.name.clone(), meta); + write_variant_meta(repo, &meta_store)?; + // Switch to the derived variant branch repo.set_head(&ref_name)?; repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?; @@ -114,29 +128,24 @@ fn process_file( ) -> Result { let file = File::open(name)?; let reader = BufReader::new(file); - let mut lines: Vec = reader.lines().collect::>()?; + let lines: Vec = reader.lines().collect::>()?; let mut output: Vec = vec![]; - let mut hidden_start: Option = Option::None; + let mut hidden_start: Option = None; let mut current_hidden_feature: Option = None; - let mut commit_cache: HashMap> = HashMap::new(); let mut blame_opts = BlameOptions::new(); let blame = repo.blame_file(Path::new(name), Some(&mut blame_opts))?; - for (i, line) in lines.iter_mut().enumerate() { + for (i, line) in lines.iter().enumerate() { let line_no = i + 1; - - let hunk = blame - .get_line(line_no) - .context("Cant get line info in hunk")?; - + let hunk = blame.get_line(line_no).context("Cant get line info")?; let oid = hunk.final_commit_id(); let line_feature = commit_cache.entry(oid).or_insert_with(|| { - let commit = repo.find_commit(oid).unwrap(); - let summary = commit.body().unwrap(); + let commit = repo.find_commit(oid).ok()?; + let summary = commit.message()?; extract_feature_meta(summary).map(|s| s.to_string()) }); @@ -146,30 +155,36 @@ fn process_file( }; if is_visible { - if let (Some(start), Some(feature)) = (hidden_start, current_hidden_feature.take()) { + // Close existing hidden block if exists + if let (Some(start), Some(feature)) = + (hidden_start.take(), current_hidden_feature.take()) + { output.push(format!( "# morph:{}-{} hidden feature '{}'", start, line_no - 1, feature )); - hidden_start = None; } output.push(line.clone()); - } else if hidden_start.is_none() { - hidden_start = Some(line_no); - current_hidden_feature = line_feature.clone(); - } else if current_hidden_feature.as_ref() != line_feature.as_ref() - && let Some(feature) = current_hidden_feature.take() - { - output.push(format!( - "// morph:{}-{} hidden feature '{}'", - hidden_start.unwrap(), - line_no, - feature - )); - hidden_start = Some(line_no); - current_hidden_feature = line_feature.clone(); + } else { + if hidden_start.is_none() { + // New hidden block starts + hidden_start = Some(line_no); + current_hidden_feature = line_feature.clone(); + } else if current_hidden_feature.as_ref() != line_feature.as_ref() { + // Feature changed while still in a hidden state: Close old, start new + if let Some(feature) = current_hidden_feature.take() { + output.push(format!( + "# morph:{}-{} hidden feature '{}'", + hidden_start.unwrap(), + line_no - 1, + feature + )); + } + hidden_start = Some(line_no); + current_hidden_feature = line_feature.clone(); + } } } @@ -181,6 +196,7 @@ fn process_file( feature )); } + Ok(output.join("\n")) } diff --git a/src/meta.rs b/src/meta.rs index 04f4b8a..b473829 100644 --- a/src/meta.rs +++ b/src/meta.rs @@ -1,7 +1,39 @@ +use std::collections::HashMap; use std::fs; use std::path::PathBuf; -use anyhow::Result; +use anyhow::{Context, Result}; +use git2::Repository; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Default)] +pub struct VariantMetaStore { + pub variants: HashMap, +} + +#[derive(Serialize, Deserialize)] +pub struct VariantMeta { + pub commit: String, + pub tree: String, + pub features: Vec, + pub created_at: String, // ISO 8601 +} + +pub fn read_variant_meta(repo: &Repository) -> Result { + let base_path = repo.path(); + let meta_path = base_path.join("variant-meta.json"); + let data = fs::read_to_string(meta_path)?; + let store = serde_json::from_str(&data).context("Failed to deserialize variant meta")?; + Ok(store) +} + +pub fn write_variant_meta(repo: &Repository, store: &VariantMetaStore) -> Result<()> { + let base_path = repo.path(); + let meta_path = base_path.join("variant-meta.json"); + let data = serde_json::to_string_pretty(&store).context("Failed to serialize variant meta")?; + fs::write(&meta_path, data)?; + Ok(()) +} pub fn read_meta(path: &PathBuf) -> Result { let data = fs::read_to_string(path)?; From 34deb10334f6effa0b57dd96785b9ef75a800d3d Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 22 Apr 2026 15:14:13 +0200 Subject: [PATCH 15/20] feat(putback): add reconstruction back from edited view --- src/cli.rs | 2 + src/commands.rs | 1 + src/commands/putback.rs | 92 +++++++++++++++++++++++++++++++++++++++++ src/main.rs | 5 ++- 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 src/commands/putback.rs diff --git a/src/cli.rs b/src/cli.rs index ef1bab5..570ca04 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -33,4 +33,6 @@ pub enum Commands { #[arg(short, long)] refresh: bool, }, + /// Reconstruct the full file from the edited variant view + Putback {}, } diff --git a/src/commands.rs b/src/commands.rs index 6a5cfdb..220c1d6 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -2,3 +2,4 @@ pub mod add; pub mod checkout; pub mod commit; pub mod init; +pub mod putback; diff --git a/src/commands/putback.rs b/src/commands/putback.rs new file mode 100644 index 0000000..3d68218 --- /dev/null +++ b/src/commands/putback.rs @@ -0,0 +1,92 @@ +use crate::meta::read_variant_meta; +use anyhow::{Result, anyhow}; +use git2::{ObjectType, Repository, TreeWalkMode, TreeWalkResult}; +use std::fs; +use std::path::PathBuf; + +pub fn run(repo: &Repository) -> Result<()> { + let meta_store = read_variant_meta(repo).unwrap_or_default(); + let origin = meta_store + .variants + .get("simple-calculator") + .ok_or_else(|| anyhow!("Variant 'simple-calculator' not found"))?; + + let commit = repo.find_commit_by_prefix(&origin.commit)?; + let tree = commit.tree()?; + + tree.walk(TreeWalkMode::PreOrder, |root, entry| { + if entry.kind() != Some(ObjectType::Blob) { + return TreeWalkResult::Ok; + } + + let Some(name) = entry.name() else { + return TreeWalkResult::Ok; + }; + + let path = PathBuf::from(format!("{}{}", root, name)); + + // Only process files that actually exist in the working directory + if !path.exists() { + return TreeWalkResult::Ok; + } + + let Ok(obj) = entry.to_object(repo) else { + return TreeWalkResult::Ok; + }; + + let Some(blob) = obj.as_blob() else { + return TreeWalkResult::Ok; + }; + + if let Err(e) = process_file(&path, blob) { + eprintln!("Failed to process {:?}: {}", path, e); + } + + TreeWalkResult::Ok + })?; + + Ok(()) +} + +fn process_file(path: &PathBuf, blob: &git2::Blob) -> Result<()> { + let original_content = std::str::from_utf8(blob.content())?; + let original_lines: Vec<&str> = original_content.split_terminator('\n').collect(); + + let variant_content = fs::read_to_string(path)?; + let mut reconstructed: Vec = vec![]; + + for line in variant_content.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with("# morph:") { + if let Some((start, end)) = extract_range(trimmed) { + // Marker range is 1-based. + for idx in start..=end { + if idx > 0 && idx <= original_lines.len() { + reconstructed.push(original_lines[idx - 1].to_string()); + } else { + eprintln!("Warning: Index {} out of range in {:?}", idx, path); + } + } + continue; + } + } + + // Not a marker so keep the line as it is + reconstructed.push(line.to_string()); + } + + fs::write(path, reconstructed.join("\n"))?; + Ok(()) +} + +fn extract_range(trimmed: &str) -> Option<(usize, usize)> { + // Expected format: "# morph:1-10 hidden feature 'name'" + let payload = trimmed.strip_prefix("# morph:")?; + let range_part = payload.split_whitespace().next()?; + let (start_str, end_str) = range_part.split_once("-")?; + + let start = start_str.parse::().ok()?; + let end = end_str.parse::().ok()?; + Some((start, end)) +} diff --git a/src/main.rs b/src/main.rs index 20b47f0..80cd7b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use clap::Parser; use git2::Repository; use cli::{Cli, Commands}; -use commands::{add, checkout, commit, init}; +use commands::{add, checkout, commit, init, putback}; fn main() -> Result<()> { let args = Cli::parse(); @@ -28,6 +28,9 @@ fn main() -> Result<()> { checkout::run(&repo, &name, refresh) .with_context(|| format!("Failed to derive variant '{}'", name))?; } + Commands::Putback {} => { + putback::run(&repo).context("Failed to sync edited view")?; + } } Ok(()) } From fb6e8774eb0640a6350288d41b58bbed7d45d1ba Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Mon, 11 May 2026 11:32:01 +0200 Subject: [PATCH 16/20] fix(add): crash when adding a directory --- src/commands/add.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/commands/add.rs b/src/commands/add.rs index 35140dc..ec138e0 100644 --- a/src/commands/add.rs +++ b/src/commands/add.rs @@ -1,7 +1,5 @@ -use std::path::Path; - use anyhow::{Context, Result}; -use git2::Repository; +use git2::{IndexAddOption, Repository}; use crate::meta::{read_meta, write_meta}; @@ -20,11 +18,9 @@ pub fn run(repo: &Repository, files: &[String], feature: &str) -> Result<()> { } let mut index = repo.index()?; - for file in files { - index - .add_path(Path::new(file)) - .with_context(|| format!("Failed to stage path '{}'", file))?; - } + index + .add_all(files, IndexAddOption::DEFAULT, None) + .with_context(|| "Failed to stage provided paths")?; index.write()?; Ok(()) From 51cec5e2e5344b78cc4e7753a89756a457aac87b Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 17 Jun 2026 22:22:46 +0200 Subject: [PATCH 17/20] fix(checkout): repair failing test cases --- src/commands/checkout.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/checkout.rs b/src/commands/checkout.rs index dd85f3a..300ce8f 100644 --- a/src/commands/checkout.rs +++ b/src/commands/checkout.rs @@ -213,7 +213,7 @@ mod tests { #[test] fn extracts_feature_when_present() { - let msg = "chore: init\n\nFeature: core"; + let msg = "chore: init\n\nFEATURE: core"; assert_eq!(extract_feature_meta(msg), Some("core")); } @@ -222,8 +222,8 @@ mod tests { let msg = "\ chore: initialize fog support in repo -ChangeId: de9378ec963973b367aa0ad706fa006af3ce65da -Feature: core"; +CHANGE_ID: de9378ec963973b367aa0ad706fa006af3ce65da +FEATURE: core"; assert_eq!(extract_feature_meta(msg), Some("core")); } From 9abee0c2feea47cf65295fc0fa458d85156698fd Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 17 Jun 2026 21:52:49 +0200 Subject: [PATCH 18/20] feat: add clafer modelling support --- Cargo.lock | 122 ++++++++++ Cargo.toml | 2 + src/main.rs | 1 + src/parser.rs | 3 + src/parser/ast.rs | 34 +++ src/parser/clafer-subset.cf | 75 ++++++ src/parser/clafer.pest | 65 +++++ src/parser/evaluator.rs | 457 ++++++++++++++++++++++++++++++++++++ src/parser/grammar.rs | 187 +++++++++++++++ 9 files changed, 946 insertions(+) create mode 100644 src/parser.rs create mode 100644 src/parser/ast.rs create mode 100644 src/parser/clafer-subset.cf create mode 100644 src/parser/clafer.pest create mode 100644 src/parser/evaluator.rs create mode 100644 src/parser/grammar.rs diff --git a/Cargo.lock b/Cargo.lock index b616c04..c2465c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -177,6 +186,35 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -209,6 +247,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -229,6 +277,8 @@ dependencies = [ "chrono", "clap", "git2", + "pest", + "pest_derive", "regex", "serde", "serde_json", @@ -539,6 +589,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -665,6 +758,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -760,6 +864,18 @@ version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -796,6 +912,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" diff --git a/Cargo.toml b/Cargo.toml index e5c93ac..6d0371d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,8 @@ anyhow = "1.0.101" chrono = "0.4.44" clap = { version = "4.5.59", features = ["derive"] } git2 = "0.20.4" +pest = "2.8.6" +pest_derive = "2.8.6" regex = "1.12.3" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" diff --git a/src/main.rs b/src/main.rs index 80cd7b8..9fd2d55 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod cli; mod commands; mod config; mod meta; +mod parser; use anyhow::{Context, Result}; use clap::Parser; diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 0000000..4e9fc35 --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,3 @@ +pub mod ast; +pub mod evaluator; +pub mod grammar; diff --git a/src/parser/ast.rs b/src/parser/ast.rs new file mode 100644 index 0000000..5eb7c12 --- /dev/null +++ b/src/parser/ast.rs @@ -0,0 +1,34 @@ +#[derive(Debug, Clone)] +pub enum Declaration { + EnumDecl(String, Vec), + Element(Element), +} + +#[derive(Debug, Clone)] +pub enum Element { + Clafer(Clafer), + Constraint(Expr), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Expr { + Ref(String), + Not(Box), + And(Box, Box), + Or(Box, Box), + Xor(Box, Box), + Implies(Box, Box), + Iff(Box, Box), + /// Outside the boolean-feature subset; always "unknown" during resolution. + Other(String), +} + +#[derive(Debug, Clone)] +pub struct Clafer { + pub is_abstract: bool, + pub gcard: Option, + pub name: String, + pub super_type: Option, + pub card: Option, + pub children: Vec, +} diff --git a/src/parser/clafer-subset.cf b/src/parser/clafer-subset.cf new file mode 100644 index 0000000..3cb8916 --- /dev/null +++ b/src/parser/clafer-subset.cf @@ -0,0 +1,75 @@ +Module. Module ::= [Declaration] ; + +EnumDecl. Declaration ::= "enum" PosIdent "=" [EnumId] ; +ElementDecl. Declaration ::= Element ; + +Clafer. Clafer ::= Abstract GCard PosIdent Super Reference Card Init Elements ; +Constraint. Constraint ::= "[" Exp "]" ; + +AbstractEmpty. Abstract ::= ; +Abstract. Abstract ::= "abstract" ; + +ElementsEmpty. Elements ::= ; +ElementsList. Elements ::= "{" [Element] "}" ; + +Subclafer. Element ::= Clafer ; +Subconstraint. Element ::= Constraint ; + +SuperEmpty. Super ::= ; +SuperSome. Super ::= ":" Exp8 ; + +ReferenceEmpty. Reference ::= ; +ReferenceSet. Reference ::= "->" Exp6 ; + +InitEmpty. Init ::= ; +InitConstant. Init ::= "=" Exp ; + +GCardEmpty. GCard ::= ; +GCardXor. GCard ::= "xor" ; +GCardOr. GCard ::= "or" ; +GCardMux. GCard ::= "mux" ; +GCardOpt. GCard ::= "opt" ; +GCardInterval. GCard ::= NCard ; + +CardEmpty. Card ::= ; +CardLone. Card ::= "?" ; +CardSome. Card ::= "+" ; +CardAny. Card ::= "*" ; +CardNum. Card ::= PosInteger ; +CardInterval. Card ::= NCard ; + +NCard. NCard ::= PosInteger ".." ExInteger ; +ExIntegerAst. ExInteger ::= "*" ; +ExIntegerNum. ExInteger ::= PosInteger ; + +-- Boolean & Relational Expressions (Precedence modeled via Exp levels) +EIff. Exp ::= Exp "<=>" Exp1 ; +EImplies. Exp1 ::= Exp1 "=>" Exp2 ; +EOr. Exp2 ::= Exp2 "||" Exp3 ; +EXor. Exp3 ::= Exp3 "xor" Exp4 ; +EAnd. Exp4 ::= Exp4 "&&" Exp5 ; +ENeg. Exp5 ::= "!" Exp5 ; +ELt. Exp6 ::= Exp6 "<" Exp7 ; +EGt. Exp6 ::= Exp6 ">" Exp7 ; +EEq. Exp6 ::= Exp6 "=" Exp7 ; +ELte. Exp6 ::= Exp6 "<=" Exp7 ; +EGte. Exp6 ::= Exp6 ">=" Exp7 ; +ENeq. Exp6 ::= Exp6 "!=" Exp7 ; +EIn. Exp6 ::= Exp6 "in" Exp7 ; +ENin. Exp6 ::= Exp6 "not" "in" Exp7 ; +EAdd. Exp7 ::= Exp7 "+" Exp8 ; +ESub. Exp7 ::= Exp7 "-" Exp8 ; +EJoin. Exp8 ::= Exp8 "." Exp9 ; + +EQuantExp. Exp9 ::= Quant Exp10 ; +ClaferId. Exp10 ::= PosIdent ; +EInt. Exp10 ::= PosInteger ; +EStr. Exp10 ::= PosString ; +EParens. Exp10 ::= "(" Exp ")" ; + +QuantNo. Quant ::= "no" ; +QuantNot. Quant ::= "not" ; +QuantLone. Quant ::= "lone" ; +QuantOne. Quant ::= "one" ; +QuantSome. Quant ::= "some" ; +QuantAll. Quant ::= "all" ; diff --git a/src/parser/clafer.pest b/src/parser/clafer.pest new file mode 100644 index 0000000..481b52a --- /dev/null +++ b/src/parser/clafer.pest @@ -0,0 +1,65 @@ +// --- Entry Point --- +module = { SOI ~ declaration* ~ EOI } + +// --- Declarations --- +declaration = { enum_decl | element } +enum_decl = { "enum" ~ ident ~ "=" ~ ident ~ ("|" ~ ident)* } +element = { clafer | constraint } + +// --- Clafer Core --- +clafer = { + abstract_mod? ~ + gcard? ~ + ident ~ + super_mod? ~ + ref_mod? ~ + card? ~ + init_mod? ~ + elements? +} + +constraint = { "[" ~ expr ~ "]" } +elements = { "{" ~ element* ~ "}" } + +// --- Modifiers --- +abstract_mod = { "abstract" } +super_mod = { ":" ~ expr } +ref_mod = { "->" ~ expr } +init_mod = { "=" ~ expr } + +// --- Cardinalities --- +gcard = { "xor" | "or" | "mux" | "opt" | ncard } +card = { "?" | "+" | "*" | ncard | int } +ncard = { int ~ ".." ~ (int | "*") } + +// --- Expressions --- +expr = { iff_expr } +iff_expr = { implies_expr ~ ("<=>" ~ implies_expr)* } +implies_expr = { or_expr ~ ("=>" ~ or_expr)* } +or_expr = { xor_expr ~ ("||" ~ xor_expr)* } +xor_expr = { and_expr ~ ("xor" ~ and_expr)* } +and_expr = { cmp_expr ~ ("&&" ~ cmp_expr)* } + +cmp_expr = { add_expr ~ (cmp_op ~ add_expr)* } +cmp_op = { "<=" | ">=" | "<" | ">" | "=" | "!=" | "not in" | "in" } + +add_expr = { join_expr ~ (add_op ~ join_expr)* } +add_op = { "+" | "-" } + +join_expr = { primary ~ ("." ~ primary)* } + +primary = { unary_op* ~ quantifier? ~ (ident | int | str | "(" ~ expr ~ ")") } +unary_op = { "!" | "-" } +quantifier = { "no" | "not" | "lone" | "one" | "some" | "all" } + +// --- Tokens --- +ident = @{ ASCII_ALPHA ~ (ASCII_ALPHANUMERIC | "_" | "'")* } +int = @{ ASCII_DIGIT+ } +str = @{ "\"" ~ (!"\"" ~ ANY)* ~ "\"" } + +// --- Implicit Whitespace & Comments (Silent) --- +WHITESPACE = _{ " " | "\t" | "\r" | "\n" } +COMMENT = _{ + ("//" ~ (!"\n" ~ ANY)*) | + ("/*" ~ (!"*/" ~ ANY)* ~ "*/") +} diff --git a/src/parser/evaluator.rs b/src/parser/evaluator.rs new file mode 100644 index 0000000..6c75766 --- /dev/null +++ b/src/parser/evaluator.rs @@ -0,0 +1,457 @@ +use super::ast::{Clafer, Declaration, Element, Expr}; +use anyhow::{Result, anyhow}; +use std::collections::{HashMap, HashSet}; + +/// A resolved instance: each leaf feature classified as included or excluded. +/// Structural containers and the abstract supertype appear in neither set. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Configuration { + pub included: HashSet, + pub excluded: HashSet, +} + +/// Per-clafer state during resolution: active / inactive / undetermined. +type Assignment = HashMap>; + +pub struct Evaluator { + // Global registry of every Clafer definition, keyed by name. + pub registry: HashMap, +} + +impl Evaluator { + pub fn new(decls: Vec) -> Self { + let mut registry = HashMap::new(); + + // Index nested clafers too (e.g. SQLite inside Storage). + fn index_recursive(elements: &[Element], map: &mut HashMap) { + for el in elements { + if let Element::Clafer(c) = el { + map.insert(c.name.clone(), c.clone()); + index_recursive(&c.children, map); + } + } + } + + for decl in decls { + if let Declaration::Element(Element::Clafer(c)) = decl { + registry.insert(c.name.clone(), c.clone()); + index_recursive(&c.children, &mut registry); + } + } + + Self { registry } + } + + /// Resolves an instance against its abstract model into included/excluded leaf features. + pub fn resolve_instance(&self, instance_name: &str) -> Result { + let instance = self + .registry + .get(instance_name) + .ok_or_else(|| anyhow!("Instance '{}' not found in model", instance_name))?; + let super_name = instance + .super_type + .as_ref() + .ok_or_else(|| anyhow!("Instance '{}' has no super type", instance_name))?; + let supertype = self + .registry + .get(super_name) + .ok_or_else(|| anyhow!("Super type '{}' of instance '{}' not found", super_name, instance_name))?; + + let mut subtree_names = HashSet::new(); + self.collect_subtree_names(supertype, &mut subtree_names); + + let mut assignment: Assignment = subtree_names.iter().map(|n| (n.clone(), None)).collect(); + try_assign(&mut assignment, super_name, true); + + // Seed explicit instance-body selections first so they win over inference. + let mut instance_constraints = Vec::new(); + collect_constraints(&instance.children, &mut instance_constraints); + for expr in &instance_constraints { + propagate_expr(expr, true, &mut assignment); + } + + // All subtree constraints for the fixed-point loop; re-asserting the + // instance constraints is a harmless no-op. + let mut constraints = instance_constraints; + collect_constraints(&supertype.children, &mut constraints); + + loop { + let mut changed = false; + changed |= self.propagate_mandatory_children(&subtree_names, &mut assignment); + changed |= self.propagate_group_cardinality(&subtree_names, &mut assignment); + for expr in &constraints { + changed |= propagate_expr(expr, true, &mut assignment); + } + if !changed { + break; + } + } + + let mut config = Configuration::default(); + for name in &subtree_names { + if name == super_name { + continue; + } + let Some(clafer) = self.registry.get(name) else { + continue; + }; + if clafer.is_abstract { + continue; + } + let is_structural_leaf = !clafer.children.iter().any(|e| matches!(e, Element::Clafer(_))); + if !is_structural_leaf { + continue; + } + match assignment.get(name).copied().flatten() { + Some(true) => { + config.included.insert(name.clone()); + } + _ => { + config.excluded.insert(name.clone()); + } + } + } + Ok(config) + } + + /// Collects every clafer name nested under `root` (inclusive), bounding the + /// resolution so unrelated declarations elsewhere don't leak in. + fn collect_subtree_names(&self, root: &Clafer, into: &mut HashSet) { + into.insert(root.name.clone()); + for el in &root.children { + if let Element::Clafer(child) = el { + self.collect_subtree_names(child, into); + } + } + } + + /// For each active clafer without a `gcard`, forces its mandatory-`card` + /// children active. Clafers with a `gcard` are handled by group-cardinality rules. + fn propagate_mandatory_children(&self, subtree_names: &HashSet, assignment: &mut Assignment) -> bool { + let mut changed = false; + for name in subtree_names { + if assignment.get(name).copied().flatten() != Some(true) { + continue; + } + let Some(clafer) = self.registry.get(name) else { continue }; + if clafer.gcard.is_some() { + continue; + } + for el in &clafer.children { + if let Element::Clafer(child) = el + && is_mandatory_card(&child.card) + { + changed |= try_assign(assignment, &child.name, true); + } + } + } + changed + } + + /// For each active clafer with a `gcard`, enforces its min/max bound: at max, + /// remaining unknown children go false; when all are needed for min, they go + /// true. Covers `xor`/`mux`/`or`/`opt`/explicit ncard. + fn propagate_group_cardinality(&self, subtree_names: &HashSet, assignment: &mut Assignment) -> bool { + let mut changed = false; + for name in subtree_names { + if assignment.get(name).copied().flatten() != Some(true) { + continue; + } + let Some(clafer) = self.registry.get(name) else { continue }; + let Some(gcard) = &clafer.gcard else { continue }; + let (min, max) = gcard_bounds(gcard); + + let direct_children: Vec<&str> = clafer + .children + .iter() + .filter_map(|e| if let Element::Clafer(c) = e { Some(c.name.as_str()) } else { None }) + .collect(); + + let true_count = direct_children + .iter() + .filter(|c| assignment.get(**c).copied().flatten() == Some(true)) + .count(); + let unknown: Vec<&str> = direct_children + .iter() + .filter(|c| assignment.get(**c).copied().flatten().is_none()) + .copied() + .collect(); + + if let Some(max) = max + && true_count >= max + { + for child in &unknown { + changed |= try_assign(assignment, child, false); + } + continue; + } + if min > 0 && true_count + unknown.len() == min { + for child in &unknown { + changed |= try_assign(assignment, child, true); + } + } + } + changed + } +} + +/// Gathers every `Element::Constraint` in `elements`, including nested clafers. +fn collect_constraints(elements: &[Element], out: &mut Vec) { + for el in elements { + match el { + Element::Constraint(expr) => out.push(expr.clone()), + Element::Clafer(c) => collect_constraints(&c.children, out), + } + } +} + +/// Sets `name` to `value` only if currently unknown; returns whether it changed. +/// Conflicts are dropped with a warning, keeping assignments monotonic +/// (`None -> Some` only) so the resolution loop terminates and explicit selections win. +fn try_assign(assignment: &mut Assignment, name: &str, value: bool) -> bool { + match assignment.get(name).copied() { + Some(Some(existing)) if existing == value => false, + Some(Some(existing)) => { + eprintln!( + "warning: conflicting assignment for '{}': already {}, ignoring {}", + name, existing, value + ); + false + } + Some(None) => { + assignment.insert(name.to_string(), Some(value)); + true + } + None => { + eprintln!("warning: reference to unknown feature '{}'", name); + false + } + } +} + +/// Three-valued evaluation of an already-known (partial) assignment. +fn eval_expr(expr: &Expr, assignment: &Assignment) -> Option { + match expr { + Expr::Ref(name) => assignment.get(name).copied().flatten(), + Expr::Not(a) => eval_expr(a, assignment).map(|v| !v), + Expr::And(a, b) => match (eval_expr(a, assignment), eval_expr(b, assignment)) { + (Some(false), _) | (_, Some(false)) => Some(false), + (Some(true), Some(true)) => Some(true), + _ => None, + }, + Expr::Or(a, b) => match (eval_expr(a, assignment), eval_expr(b, assignment)) { + (Some(true), _) | (_, Some(true)) => Some(true), + (Some(false), Some(false)) => Some(false), + _ => None, + }, + Expr::Implies(l, r) => { + let lv = eval_expr(l, assignment); + let rv = eval_expr(r, assignment); + if lv == Some(false) || rv == Some(true) { + Some(true) + } else if lv == Some(true) && rv == Some(false) { + Some(false) + } else { + None + } + } + Expr::Xor(a, b) => match (eval_expr(a, assignment), eval_expr(b, assignment)) { + (Some(av), Some(bv)) => Some(av != bv), + _ => None, + }, + Expr::Iff(a, b) => match (eval_expr(a, assignment), eval_expr(b, assignment)) { + (Some(av), Some(bv)) => Some(av == bv), + _ => None, + }, + Expr::Other(_) => None, + } +} + +/// Pushes a required truth value down through `expr`, asserting whatever can be +/// inferred about its subexpressions. Returns whether any assignment changed. +fn propagate_expr(expr: &Expr, required: bool, assignment: &mut Assignment) -> bool { + match expr { + Expr::Ref(name) => try_assign(assignment, name, required), + Expr::Not(a) => propagate_expr(a, !required, assignment), + Expr::And(a, b) => { + if required { + propagate_expr(a, true, assignment) | propagate_expr(b, true, assignment) + } else { + let mut changed = false; + if eval_expr(a, assignment) == Some(true) { + changed |= propagate_expr(b, false, assignment); + } + if eval_expr(b, assignment) == Some(true) { + changed |= propagate_expr(a, false, assignment); + } + changed + } + } + Expr::Or(a, b) => { + if required { + let mut changed = false; + if eval_expr(a, assignment) == Some(false) { + changed |= propagate_expr(b, true, assignment); + } + if eval_expr(b, assignment) == Some(false) { + changed |= propagate_expr(a, true, assignment); + } + changed + } else { + propagate_expr(a, false, assignment) | propagate_expr(b, false, assignment) + } + } + Expr::Implies(l, r) => { + if required { + let mut changed = false; + if eval_expr(l, assignment) == Some(true) { + changed |= propagate_expr(r, true, assignment); + } + if eval_expr(r, assignment) == Some(false) { + changed |= propagate_expr(l, false, assignment); + } + changed + } else { + propagate_expr(l, true, assignment) | propagate_expr(r, false, assignment) + } + } + Expr::Xor(a, b) => { + let mut changed = false; + if let Some(av) = eval_expr(a, assignment) { + changed |= propagate_expr(b, av != required, assignment); + } + if let Some(bv) = eval_expr(b, assignment) { + changed |= propagate_expr(a, bv != required, assignment); + } + changed + } + Expr::Iff(a, b) => { + let mut changed = false; + if let Some(av) = eval_expr(a, assignment) { + changed |= propagate_expr(b, av == required, assignment); + } + if let Some(bv) = eval_expr(b, assignment) { + changed |= propagate_expr(a, bv == required, assignment); + } + changed + } + Expr::Other(_) => false, + } +} + +/// Whether a `card` is mandatory (min >= 1): absent, `"+"`, or a range with min >= 1. +/// `"?"`/`"*"`/min-0 ranges are optional. +fn is_mandatory_card(card: &Option) -> bool { + match card.as_deref() { + None => true, + Some("+") => true, + Some("?") | Some("*") => false, + Some(s) => ncard_min(s).map(|min| min >= 1).unwrap_or(true), + } +} + +/// (min, max) bounds for a group cardinality. `max = None` means unbounded. +fn gcard_bounds(gcard: &str) -> (usize, Option) { + match gcard { + "xor" => (1, Some(1)), + "mux" => (0, Some(1)), + "or" => (1, None), + "opt" => (0, None), + s => { + let min = ncard_min(s).unwrap_or(0); + let max = ncard_max(s); + (min, max) + } + } +} + +/// Parses the min of a cardinality string like `"1..3"`, `"1..*"`, or `"2"`. +fn ncard_min(s: &str) -> Option { + let min_part = s.split("..").next().unwrap_or(s); + min_part.trim().parse::().ok() +} + +/// Parses the max of a cardinality string: `"1..3"` -> `Some(3)`, `"1..*"` -> `None`, +/// bare `"2"` -> `Some(2)`. +fn ncard_max(s: &str) -> Option { + match s.split_once("..") { + Some((_, "*")) => None, + Some((_, max)) => max.trim().parse::().ok(), + None => s.trim().parse::().ok(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::grammar::parse_clafer_module; + + const MODEL: &str = r#" +abstract POSSystem { + xor Storage { + SQLite + Postgres + } + Scanner ? + DigitalPayment ? + PhysicalCash ? + ReceiptPrinter ? + + // Dependencies + [ PhysicalCash => ReceiptPrinter ] + [ DigitalPayment || PhysicalCash ] +} + +// Instance 1: A lightweight, digital-only tablet +MinimalistKiosk : POSSystem { + [ SQLite ] + [ DigitalPayment ] + [ no PhysicalCash ] + [ no Scanner ] + [ no ReceiptPrinter ] +} + +// Instance 2: A robust, hardware-heavy checkout +FullServiceStation : POSSystem { + [ Postgres ] + [ DigitalPayment ] + [ PhysicalCash ] + [ Scanner ] +} +"#; + + fn set(names: &[&str]) -> HashSet { + names.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn resolves_full_service_station() { + let decls = parse_clafer_module(MODEL); + let eval = Evaluator::new(decls); + let config = eval.resolve_instance("FullServiceStation").unwrap(); + assert_eq!( + config.included, + set(&["Postgres", "DigitalPayment", "PhysicalCash", "Scanner", "ReceiptPrinter"]) + ); + assert_eq!(config.excluded, set(&["SQLite"])); + } + + #[test] + fn resolves_minimalist_kiosk() { + let decls = parse_clafer_module(MODEL); + let eval = Evaluator::new(decls); + let config = eval.resolve_instance("MinimalistKiosk").unwrap(); + assert_eq!(config.included, set(&["SQLite", "DigitalPayment"])); + assert_eq!( + config.excluded, + set(&["Postgres", "PhysicalCash", "Scanner", "ReceiptPrinter"]) + ); + } + + #[test] + fn unknown_instance_errors() { + let decls = parse_clafer_module(MODEL); + let eval = Evaluator::new(decls); + assert!(eval.resolve_instance("NoSuchInstance").is_err()); + } +} diff --git a/src/parser/grammar.rs b/src/parser/grammar.rs new file mode 100644 index 0000000..f5d381b --- /dev/null +++ b/src/parser/grammar.rs @@ -0,0 +1,187 @@ +use pest::Parser; +use pest::iterators::Pair; +use pest_derive::Parser; + +use crate::parser::ast::{Clafer, Declaration, Element, Expr}; + +#[derive(Parser)] +#[grammar = "parser/clafer.pest"] +pub struct ClaferParser; + +pub fn parse_clafer_module(input: &str) -> Vec { + let pairs = ClaferParser::parse(Rule::module, input) + .expect("Parsing copied 128 chars to clipboard failed") + .next() + .unwrap(); + + let mut declarations = Vec::new(); + + for pair in pairs.into_inner() { + match pair.as_rule() { + Rule::declaration => { + let inner = pair.into_inner().next().unwrap(); + match inner.as_rule() { + Rule::enum_decl => { + // Idents are flat in `.into_inner()`; the first is the enum's name. + let idents: Vec = + inner.into_inner().map(|p| p.as_str().to_string()).collect(); + if let Some((name, members)) = idents.split_first() { + declarations.push(Declaration::EnumDecl(name.clone(), members.to_vec())); + } + } + Rule::element => { + declarations.push(Declaration::Element(parse_element(inner))); + } + _ => {} + } + } + Rule::EOI => (), // End of Input + _ => unreachable!(), + } + } + declarations +} + +fn parse_element(pair: Pair) -> Element { + let inner = pair.into_inner().next().unwrap(); + match inner.as_rule() { + Rule::clafer => Element::Clafer(parse_clafer(inner)), + Rule::constraint => { + // constraint = { "[" ~ expr ~ "]" } + let expr_pair = inner.into_inner().next().unwrap(); + Element::Constraint(parse_expr(expr_pair)) + } + _ => unreachable!(), + } +} + +/// Parses the boolean-expression grammar (`expr` through `primary`) into an `Expr` +/// tree. Anything outside the boolean subset is preserved as raw `Expr::Other` text, +/// staying inert (always "unknown") during evaluation instead of panicking. +fn parse_expr(pair: Pair) -> Expr { + match pair.as_rule() { + Rule::expr => parse_expr(pair.into_inner().next().unwrap()), + Rule::iff_expr => fold_connective(pair, Expr::Iff), + Rule::implies_expr => fold_connective(pair, Expr::Implies), + Rule::or_expr => fold_connective(pair, Expr::Or), + Rule::xor_expr => fold_connective(pair, Expr::Xor), + Rule::and_expr => fold_connective(pair, Expr::And), + Rule::cmp_expr => parse_operator_chain(pair, Rule::cmp_op), + Rule::add_expr => parse_operator_chain(pair, Rule::add_op), + Rule::join_expr => { + let text = pair.as_str().trim().to_string(); + let operands: Vec> = pair.into_inner().collect(); + match <[Pair; 1]>::try_from(operands) { + Ok([only]) => parse_expr(only), + Err(_) => Expr::Other(text), // dotted join, out of scope + } + } + Rule::primary => parse_primary(pair), + Rule::ident => Expr::Ref(pair.as_str().to_string()), + _ => Expr::Other(pair.as_str().trim().to_string()), + } +} + +/// Left-folds a level like `and_expr = { cmp_expr ~ ("&&" ~ cmp_expr)* }` into a +/// chain of binary nodes. The operator is a bare literal (not in `.into_inner()`), +/// so the level's single connective applies to every operand join. +fn fold_connective(pair: Pair, ctor: fn(Box, Box) -> Expr) -> Expr { + let mut operands = pair.into_inner().map(parse_expr); + let first = operands + .next() + .expect("expression level must have at least one operand"); + operands.fold(first, |acc, next| ctor(Box::new(acc), Box::new(next))) +} + +/// Handles levels with a named operator rule (`cmp_op`, `add_op`). These are outside +/// the boolean subset: a lone operand passes through, a real chain becomes `Expr::Other`. +fn parse_operator_chain(pair: Pair, op_rule: Rule) -> Expr { + let text = pair.as_str().trim().to_string(); + let operands: Vec> = pair.into_inner().filter(|p| p.as_rule() != op_rule).collect(); + match <[Pair; 1]>::try_from(operands) { + Ok([only]) => parse_expr(only), + Err(_) => Expr::Other(text), + } +} + +/// `primary = { unary_op* ~ quantifier? ~ (ident | int | str | "(" ~ expr ~ ")") }` +fn parse_primary(pair: Pair) -> Expr { + let full_text = pair.as_str().trim().to_string(); + let parts: Vec> = pair.into_inner().collect(); + let Some((terminal, prefixes)) = parts.split_last() else { + return Expr::Other(full_text); + }; + + // Arithmetic negation ("-") is outside the boolean subset; bail out whole. + if prefixes + .iter() + .any(|p| p.as_rule() == Rule::unary_op && p.as_str() == "-") + { + return Expr::Other(full_text); + } + + let mut expr = parse_expr(terminal.clone()); + + // Apply prefixes innermost-first: the one nearest the terminal binds first. + for prefix in prefixes.iter().rev() { + match prefix.as_rule() { + Rule::unary_op if prefix.as_str() == "!" => { + expr = Expr::Not(Box::new(expr)); + } + Rule::quantifier if matches!(prefix.as_str(), "no" | "not") => { + expr = Expr::Not(Box::new(expr)); + } + // "lone"/"one"/"some"/"all": pass through; multiplicity quantifiers + // have no precise meaning in this single-instance boolean subset. + _ => {} + } + } + + expr +} + +fn parse_clafer(pair: Pair) -> Clafer { + let mut is_abstract = false; + let mut gcard = None; + let mut name = String::new(); + let mut super_type = None; + let mut card = None; + let mut children = Vec::new(); + + // Walk through the components of the clafer + for inner in pair.into_inner() { + match inner.as_rule() { + Rule::abstract_mod => is_abstract = true, + Rule::gcard => gcard = Some(inner.as_str().to_string()), + Rule::ident => name = inner.as_str().to_string(), + Rule::super_mod => { + // super_mod is ":" ~ expr. Parse via parse_expr rather than raw + // span text, which can leak trailing whitespace from pest. + let expr_pair = inner.into_inner().next().unwrap(); + if let Expr::Ref(name) = parse_expr(expr_pair) { + super_type = Some(name); + } + } + Rule::card => card = Some(inner.as_str().to_string()), + Rule::elements => { + // Recursively parse children inside { } + for el in inner.into_inner() { + if el.as_rule() == Rule::element { + children.push(parse_element(el)); + } + } + } + // ref_mod/init_mod are UML/attribute concerns, not feature modeling. + _ => {} + } + } + + Clafer { + is_abstract, + gcard, + name, + super_type, + card, + children, + } +} From fe7fa14ea38a034f249abe619fe17c3a934ce50e Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 8 Jul 2026 11:13:19 +0200 Subject: [PATCH 19/20] feat(checkout): derive variants from clafer model --- Cargo.lock | 77 ---------------------------------------- Cargo.toml | 1 - src/cli.rs | 4 +-- src/commands/checkout.rs | 42 +++++++++++++--------- src/commands/init.rs | 30 +++++++++++----- src/config.rs | 28 --------------- src/main.rs | 1 - 7 files changed, 49 insertions(+), 134 deletions(-) delete mode 100644 src/config.rs diff --git a/Cargo.lock b/Cargo.lock index c2465c2..886d8ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -226,12 +226,6 @@ dependencies = [ "syn", ] -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -282,7 +276,6 @@ dependencies = [ "regex", "serde", "serde_json", - "toml", ] [[package]] @@ -300,12 +293,6 @@ dependencies = [ "url", ] -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - [[package]] name = "heck" version = "0.5.0" @@ -438,16 +425,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -749,15 +726,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_spanned" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" -dependencies = [ - "serde_core", -] - [[package]] name = "sha2" version = "0.10.9" @@ -825,45 +793,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "toml" -version = "1.1.0+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] - -[[package]] -name = "toml_datetime" -version = "1.1.0+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_parser" -version = "1.1.0+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" -dependencies = [ - "winnow", -] - -[[package]] -name = "toml_writer" -version = "1.1.0+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" - [[package]] name = "typenum" version = "1.20.0" @@ -1040,12 +969,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "winnow" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" - [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 6d0371d..74fe0de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,4 +17,3 @@ pest_derive = "2.8.6" regex = "1.12.3" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" -toml = "1.0.7" diff --git a/src/cli.rs b/src/cli.rs index 570ca04..b1f8eeb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -25,9 +25,9 @@ pub enum Commands { #[arg(short, long)] message: String, }, - /// Checkout a variant with the features specified in fog.toml + /// Checkout a variant derived from the feature model in model.cfr Checkout { - /// Name of the variant entry in fog.toml + /// Name of the variant instance in model.cfr name: String, /// Refresh the variant if it already exists #[arg(short, long)] diff --git a/src/commands/checkout.rs b/src/commands/checkout.rs index 300ce8f..10c8585 100644 --- a/src/commands/checkout.rs +++ b/src/commands/checkout.rs @@ -8,8 +8,9 @@ use chrono::Utc; use git2::{BlameOptions, Commit, ObjectType, Oid, Repository, Status, Tree}; use regex::Regex; -use crate::config::{Variant, read_config}; use crate::meta::{VariantMeta, read_variant_meta, write_variant_meta}; +use crate::parser::evaluator::Evaluator; +use crate::parser::grammar::parse_clafer_module; pub fn run(repo: &Repository, name: &str, refresh: bool) -> Result<()> { let statuses = repo.statuses(None)?; @@ -21,8 +22,8 @@ pub fn run(repo: &Repository, name: &str, refresh: bool) -> Result<()> { } } - let variant_spec = get_variant_spec(repo, name).context("Failed to get variant spec")?; - let ref_name = format!("refs/heads/variant/{}", variant_spec.name); + let features = derive_features(repo, name).context("Failed to derive variant from model")?; + let ref_name = format!("refs/heads/variant/{}", name); let variant_ref = repo.find_reference(&ref_name).ok(); @@ -38,7 +39,7 @@ pub fn run(repo: &Repository, name: &str, refresh: bool) -> Result<()> { }; let parent_refs: Vec<&Commit> = parent_refs.iter().collect(); - let target_features: HashSet = variant_spec.features.iter().cloned().collect(); + let target_features: HashSet = features.iter().cloned().collect(); let sig = repo.signature()?; let head = repo.head()?; @@ -52,10 +53,7 @@ pub fn run(repo: &Repository, name: &str, refresh: bool) -> Result<()> { Some(&ref_name), &sig, &sig, - &format!( - "variant derivation with features {:?}", - variant_spec.features - ), + &format!("variant derivation with features {:?}", features), &variant_tree, &parent_refs, )?; @@ -65,10 +63,10 @@ pub fn run(repo: &Repository, name: &str, refresh: bool) -> Result<()> { let meta = VariantMeta { commit: head_commit.id().to_string(), tree: tree.id().to_string(), - features: variant_spec.features.clone(), + features: features.clone(), created_at: Utc::now().to_rfc3339(), }; - meta_store.variants.insert(variant_spec.name.clone(), meta); + meta_store.variants.insert(name.to_string(), meta); write_variant_meta(repo, &meta_store)?; // Switch to the derived variant branch @@ -77,13 +75,23 @@ pub fn run(repo: &Repository, name: &str, refresh: bool) -> Result<()> { Ok(()) } -fn get_variant_spec(repo: &Repository, name: &str) -> Result { - let config = read_config(repo)?; - let variant = config - .variants - .get(name) - .ok_or_else(|| anyhow!("no variant found with name '{}'", name))?; - Ok(variant.clone()) +/// Resolves the instance `name` against the project's `model.cfr` feature model, +/// returning the sorted list of included leaf features. +fn derive_features(repo: &Repository, name: &str) -> Result> { + let base_path = repo + .path() + .parent() + .context("Repository has no parent directory")?; + let model_path = base_path.join("model.cfr"); + let source = std::fs::read_to_string(&model_path) + .with_context(|| format!("Failed to read model file {}", model_path.display()))?; + + let decls = parse_clafer_module(&source); + let config = Evaluator::new(decls).resolve_instance(name)?; + + let mut features: Vec = config.included.into_iter().collect(); + features.sort(); + Ok(features) } fn build_variant_tree( diff --git a/src/commands/init.rs b/src/commands/init.rs index 60dc5f1..a671793 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -4,8 +4,22 @@ use std::io::Write; use anyhow::{Context, Result}; use git2::Repository; -const DEFAULT_CONFIG: &str = r#"# git-varcs configuration file -# You can define variants and associate features here +const DEFAULT_MODEL: &str = r#"// git-varcs feature model +// Describe your feature model as an abstract clafer, then declare each +// variant as a concrete instance that selects the features it needs. +// +// abstract Product { +// xor Storage { +// SQLite +// Postgres +// } +// Scanner ? +// } +// +// MyVariant : Product { +// [ SQLite ] +// [ Scanner ] +// } "#; pub fn run(repo: &Repository) -> Result<()> { @@ -13,7 +27,7 @@ pub fn run(repo: &Repository) -> Result<()> { .path() .parent() .context("Repository has no parent directory")?; - let config_path = base_path.join("varcs.toml"); + let model_path = base_path.join("model.cfr"); let meta_path = base_path.join(".git/varcs"); fs::create_dir(meta_path)?; @@ -21,12 +35,12 @@ pub fn run(repo: &Repository) -> Result<()> { let mut file = fs::OpenOptions::new() .write(true) .create_new(true) - .open(&config_path) - .with_context(|| format!("Failed to create config file at path: {:?}", config_path))?; + .open(&model_path) + .with_context(|| format!("Failed to create model file at path: {:?}", model_path))?; - file.write(DEFAULT_CONFIG.as_bytes()) - .with_context(|| format!("Failed to write default config to: {:?}", config_path))?; + file.write(DEFAULT_MODEL.as_bytes()) + .with_context(|| format!("Failed to write default model to: {:?}", model_path))?; - println!("Initialized config file at {}", config_path.display()); + println!("Initialized feature model at {}", model_path.display()); Ok(()) } diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index bf0b62b..0000000 --- a/src/config.rs +++ /dev/null @@ -1,28 +0,0 @@ -use std::collections::HashMap; -use std::fs; - -use anyhow::{Context, Result}; -use git2::Repository; -use serde::Deserialize; - -#[derive(Deserialize)] -pub struct Config { - pub variants: HashMap, -} - -#[derive(Deserialize, Clone)] -pub struct Variant { - pub name: String, - pub features: Vec, -} - -pub fn read_config(repo: &Repository) -> Result { - let base_path = repo - .path() - .parent() - .context("Repository has no parent directory")?; - let config_path = base_path.join("varcs.toml"); - let data = fs::read_to_string(config_path)?; - let config = toml::from_str(&data)?; - Ok(config) -} diff --git a/src/main.rs b/src/main.rs index 9fd2d55..d5b44b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,5 @@ mod cli; mod commands; -mod config; mod meta; mod parser; From 3c0e2b8c1b7403401242796ff015ef5494cd2797 Mon Sep 17 00:00:00 2001 From: "Aashutosh A." Date: Wed, 8 Jul 2026 12:35:25 +0200 Subject: [PATCH 20/20] fix(checkout): ignore untracked/ignored files in clean-tree check --- src/commands/checkout.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/commands/checkout.rs b/src/commands/checkout.rs index 10c8585..6d6c6fd 100644 --- a/src/commands/checkout.rs +++ b/src/commands/checkout.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; use chrono::Utc; -use git2::{BlameOptions, Commit, ObjectType, Oid, Repository, Status, Tree}; +use git2::{BlameOptions, Commit, ObjectType, Oid, Repository, Status, StatusOptions, Tree}; use regex::Regex; use crate::meta::{VariantMeta, read_variant_meta, write_variant_meta}; @@ -13,13 +13,12 @@ use crate::parser::evaluator::Evaluator; use crate::parser::grammar::parse_clafer_module; pub fn run(repo: &Repository, name: &str, refresh: bool) -> Result<()> { - let statuses = repo.statuses(None)?; - for entry in statuses.iter() { - let s = entry.status(); - if s != Status::CURRENT { - println!("Repository is not clean!"); - return Ok(()); - } + // Only uncommitted changes to tracked files should block a derivation. + let mut status_opts = StatusOptions::new(); + status_opts.include_untracked(false).include_ignored(false); + let statuses = repo.statuses(Some(&mut status_opts))?; + if statuses.iter().any(|e| e.status() != Status::CURRENT) { + anyhow::bail!("Repository has uncommitted changes; commit or stash them first."); } let features = derive_features(repo, name).context("Failed to derive variant from model")?;