diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..2cbadc6 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[env] +PYO3_PYTHON = "python3.12" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..66cfdab --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,24 @@ +name: Cargo Build & Test + +on: + push: + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + build_and_test: + name: Rust project - latest + runs-on: ubuntu-latest + strategy: + matrix: + toolchain: + - stable + - beta + - nightly + steps: + - uses: actions/checkout@v4 + - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} + - run: cargo build --verbose + - run: cargo test --verbose diff --git a/Cargo.lock b/Cargo.lock index d5bbbcc..10c88cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,6 +70,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "assert_cmd" version = "2.1.1" @@ -85,12 +91,83 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ast-grep-core" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "302b68ec51d0990cf24ccc77a98297888145bfe69a078f11f1c51c961bec1b14" +dependencies = [ + "bit-set", + "regex", + "thiserror 1.0.69", + "tree-sitter-facade-sg", +] + +[[package]] +name = "ast-grep-language" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a028281965a8ebc134ba31b3cb15d55eaa7a2c9b1dbf3127856b6a3263b8e3e5" +dependencies = [ + "ast-grep-core", + "ignore", + "serde", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-c-sharp", + "tree-sitter-cpp", + "tree-sitter-css", + "tree-sitter-dart", + "tree-sitter-elixir", + "tree-sitter-go", + "tree-sitter-haskell", + "tree-sitter-html", + "tree-sitter-java", + "tree-sitter-javascript-sg", + "tree-sitter-json", + "tree-sitter-kotlin", + "tree-sitter-lua", + "tree-sitter-php", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-scala", + "tree-sitter-swift", + "tree-sitter-typescript", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "2.10.0" @@ -116,13 +193,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "cc" -version = "1.2.44" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" -dependencies = [ - "find-msvc-tools", - "shlex", -] +checksum = "066fce287b1d4eafef758e89e09d724a24808a9196fe9756b8ca90e86d0719a2" [[package]] name = "cfg-if" @@ -139,6 +212,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -217,6 +291,31 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "difflib" version = "0.4.0" @@ -278,12 +377,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "find-msvc-tools" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" - [[package]] name = "float-cmp" version = "0.10.0" @@ -316,6 +409,19 @@ dependencies = [ "wasip2", ] +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "hashbrown" version = "0.16.0" @@ -328,6 +434,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -352,6 +467,22 @@ dependencies = [ "cc", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.12.0" @@ -364,9 +495,9 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.2" +version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade6dfcba0dfb62ad59e59e7241ec8912af34fd29e0e743e3db992bd278e8b65" +checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" dependencies = [ "console", "portable-atomic", @@ -406,6 +537,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.177" @@ -434,6 +571,15 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.7.6" @@ -455,6 +601,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -482,6 +637,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + [[package]] name = "portable-atomic" version = "1.11.1" @@ -590,9 +751,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -607,7 +768,9 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" name = "r2x" version = "0.0.4" dependencies = [ + "anyhow", "assert_cmd", + "atty", "chrono", "clap", "colored", @@ -615,17 +778,88 @@ dependencies = [ "indicatif", "once_cell", "predicates", - "pyo3", + "r2x-ast", + "r2x-config", + "r2x-logger", + "r2x-manifest", + "r2x-python", "regex", "serde", "serde_json", "serde_yaml", "tempfile", - "thiserror", + "thiserror 2.0.17", "toml", + "tracing", + "tracing-subscriber", + "walkdir", "which", ] +[[package]] +name = "r2x-ast" +version = "0.0.1" +dependencies = [ + "anyhow", + "ast-grep-core", + "ast-grep-language", + "r2x-logger", + "r2x-manifest", + "tempfile", + "toml", + "tracing", + "walkdir", +] + +[[package]] +name = "r2x-config" +version = "0.0.1" +dependencies = [ + "dirs", + "serde", + "toml", + "which", +] + +[[package]] +name = "r2x-logger" +version = "0.0.1" +dependencies = [ + "chrono", + "colored", + "dirs", + "indicatif", +] + +[[package]] +name = "r2x-manifest" +version = "0.0.1" +dependencies = [ + "anyhow", + "chrono", + "dirs", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.17", + "toml", + "tracing", +] + +[[package]] +name = "r2x-python" +version = "0.0.1" +dependencies = [ + "once_cell", + "pyo3", + "r2x-config", + "r2x-logger", + "r2x-manifest", + "serde_json", + "thiserror 2.0.17", + "tracing", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -634,7 +868,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror", + "thiserror 2.0.17", ] [[package]] @@ -691,6 +925,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "serde" version = "1.0.228" @@ -757,10 +1000,19 @@ dependencies = [ ] [[package]] -name = "shlex" -version = "1.3.0" +name = "sharded-slab" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "strsim" @@ -770,9 +1022,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.108" +version = "2.0.110" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" dependencies = [ "proc-macro2", "quote", @@ -804,13 +1056,33 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -824,6 +1096,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "toml" version = "0.9.8" @@ -863,6 +1144,323 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "tree-sitter" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "705bf7c0958d0171dd7d3a6542f2f4f21d87ed5f1dc8db52919d3a6bed9a359a" +dependencies = [ + "cc", + "regex", +] + +[[package]] +name = "tree-sitter-bash" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5244703ad2e08a616d859a0557d7aa290adcd5e0990188a692e628ffe9dce40" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-c" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f956d5351d62652864a4ff3ae861747e7a1940dc96c9998ae400ac0d3ce30427" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8997ad04502208449025114e434c9024a33a74e700513c702a9d2cac6522a771" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d509a22a992790d38f2c291961ff8a1ff016c437c7ec6befc9220b8eec8918c" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-css" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e08e324b1cf60fd3291774b49724c66de2ce8fcf4d358d0b4b82e37b41b1c9b" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-dart" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f1f70b80ce41343e14aafcef67b5ba2e9de89587535b4aabbabb8036f4e38a" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-elixir" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94bf7f057768b1cab2ee1f14812ed4ae33f9e04d09254043eeaa797db4ef70" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-facade-sg" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "906493dd94525dbb0cc38af596432245bede875810a4804dccd72433f450d223" +dependencies = [ + "js-sys", + "tree-sitter", + "wasm-bindgen", + "web-sys", + "web-tree-sitter-sg", +] + +[[package]] +name = "tree-sitter-go" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d702a98d3c7e70e466456e58ff2b1ac550bf1e29b97e5770676d2fdabec00d" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-haskell" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25a7e6c73cc1cbe0c0b7dbd5406e7b3485b370bd61c5d8d852ae0781f9bf9a" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-html" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8766b5ad3721517f8259e6394aefda9c686aebf7a8c74ab8624f2c3b46902fd5" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-java" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33bc21adf831a773c075d9d00107ab43965e6a6ea7607b47fd9ec6f3db4b481b" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-javascript-sg" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bcc0db43c6e12fa3cc61f6341a36aba80ae95eeaf575a4292bad7a1960488f2" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-json" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b737dcb73c35d74b7d64a5f3dde158113c86a012bf3cee2bfdf2150d23b05db" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-kotlin" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54ff60aeb036f5762515ceb31404512ea4f9599764bcd3857074bb82867bdd34" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-lua" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b9fe6fc87bd480e1943fc1fcb02453fb2da050e4e8ce0daa67d801544046856" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-php" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1be890bd043986cc26b69968698e508dbd40060805e482f226dc873a63a88d60" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-python" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4066c6cf678f962f8c2c4561f205945c84834cce73d981e71392624fdc390a9" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-ruby" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0031f687c0772f2dad7b77104c43428611099a1804c81244ada21560f41f0b1" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "277690f420bf90741dea984f3da038ace46c4fe6047cba57a66822226cde1c93" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-scala" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4cb33e33a04b091b56541cf6663abba117cd2feefe2908e42cf9964d75a5edb" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-swift" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36b3097fc0b6a667acc2f54dd2d5a004ce85372397d8240354d3602863879089" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecb35d98a688378e56c18c9c159824fd16f730ccbea19aacf4f206e5d5438ed9" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "unicode-ident" version = "1.0.22" @@ -899,6 +1497,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -908,6 +1512,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -936,6 +1550,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.105" @@ -968,6 +1595,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "1.1.0" @@ -978,6 +1615,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-tree-sitter-sg" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65322f84b51c9f73698738e9d76fd3badea7c4ce584e7e19531face4c2b92acc" +dependencies = [ + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "which" version = "8.0.0" @@ -989,6 +1637,37 @@ dependencies = [ "winsafe", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index a535d3e..6d5f21b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,46 +1,19 @@ -[package] -name = "r2x" +[workspace] +members = [ + "crates/*", +] +resolver = "2" + +[workspace.package] version = "0.0.4" edition = "2021" authors = ["pesap ", "Micah Webb "] -description = "A comprehensive framework plugin manager for the r2x power systems modeling ecosystem. Simplifies discovery, installation, and management of r2x framework plugins." -license = "BSD-3-Clause" repository = "https://github.com/NREL/r2x-cli" +license = "BSD-3-Clause" homepage = "https://github.com/NREL/r2x-cli" documentation = "https://docs.rs/r2x" -readme = "README.md" -keywords = ["power-systems", "r2x", "cli", "plugin-manager", "uv"] -categories = ["command-line-utilities", "development-tools"] - -[package.metadata.wix] -upgrade-guid = "AB0643AB-0F2C-4FDC-ACFB-E7886B50614F" -path-guid = "B2C9C3FE-FD0C-4209-910C-A07D1A71C539" -license = false -eula = false - -[dependencies] -clap = { version = "4.5", features = ["derive"] } -colored = "3.0" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -toml = "0.9" -dirs = "6.0" -pyo3 = { version = "0.27", features = ["auto-initialize", "abi3-py38"] } -serde_yaml = "0.9" -regex = "1.10" -thiserror = "2.0" -once_cell = "1.19" -chrono = "0.4" -indicatif = "0.18" -which = "8.0.0" - - -[dev-dependencies] -assert_cmd = "2.1" -predicates = "3.0" -tempfile = "3.8" +rust-version = "1.72" -# The profile that 'dist' will build with [profile.dist] inherits = "release" lto = "thin" diff --git a/README.md b/README.md index 82e1cd6..54758f3 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,38 @@ A comprehensive framework plugin manager for the r2x power systems modeling ecosystem. Simplifies discovery, installation, and management of r2x framework plugins. +## 🚧 Ongoing Refactoring TODO + +We're currently refactoring the plugin discovery and manifest system to be more dynamic and maintainable: + +### Completed ✅ +- [x] Simplified `DiscoveryPlugin` structure - no hardcoded fields, uses `constructor_args` + `resolved_references` +- [x] Added dependency tracking to `Package` (`install_type`, `installed_by`, `dependencies`) +- [x] Implemented AST parser Phase 1 - extracts all constructor args dynamically +- [x] Implemented AST parser Phase 2 methods - resolves class/function references +- [x] Refactored `AstDiscovery` - returns structs instead of JSON +- [x] Reorganized manifest structure: + - `types.rs` - pure type definitions + - `manifest.rs` - all manifest operations (load, save, business logic) + - `manifest_writer.rs` - custom path utilities for testing +- [x] Removed transitional `Plugin` struct + +### In Progress 🔄 +- [ ] Refactor `plugins/discovery.rs` to use new struct-based flow (no JSON parsing) +- [ ] Implement AST parser Phase 3 - associate decorators with plugins +- [ ] Update Python bridge (`plugin_invoker.rs`) to construct Python objects from `DiscoveryPlugin.constructor_args` +- [ ] Update `commands/run.rs` to work directly with `DiscoveryPlugin` +- [ ] Update `help.rs` to work directly with `DiscoveryPlugin` +- [ ] Remove `plugin_parser.rs` (parse_plugin_json, parse_decorator_registrations) - no longer needed +- [ ] Remove `plugin_manifest.rs` entirely +- [ ] Update `plugin_cache.rs` to work with `DiscoveryPlugin` + +### Architecture Goals +- **No intermediate representations**: Work directly with manifest data +- **Python bridge constructs objects**: From `constructor_args` + `resolved_references` +- **Manifest is source of truth**: No conversions, no hardcoded field assumptions +- **Dependency tracking**: Proper cleanup when packages are removed + ## Features - Easy plugin management diff --git a/crates/r2x-ast/Cargo.toml b/crates/r2x-ast/Cargo.toml new file mode 100644 index 0000000..b8ea9d0 --- /dev/null +++ b/crates/r2x-ast/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "r2x-ast" +version = "0.0.1" +edition = { workspace = true } +authors = { workspace = true } +repository = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } +rust-version = { workspace = true } + +description = "AST discovery utilities for the r2x CLI" +publish = false + +[dependencies] +anyhow = "1.0" +ast-grep-core = "0.22" +ast-grep-language = "0.22" +r2x-manifest = { path = "../r2x-manifest" } +tracing = "0.1" +walkdir = "2.4" +toml = { version = "0.9", features = ["preserve_order"] } +r2x-logger = { path = "../r2x-logger" } + +[dev-dependencies] +tempfile = "3.10" diff --git a/crates/r2x-ast/src/decorator_scanner.rs b/crates/r2x-ast/src/decorator_scanner.rs new file mode 100644 index 0000000..da0d43e --- /dev/null +++ b/crates/r2x-ast/src/decorator_scanner.rs @@ -0,0 +1,522 @@ +use anyhow::{anyhow, Result}; +use ast_grep_core::AstGrep; +use ast_grep_language::Python; +use std::fs; +use std::path::{Path, PathBuf}; +use tracing::{debug, info}; +use walkdir::WalkDir; + +use r2x_manifest::{DecoratorRegistration, FunctionParameter, FunctionSignature, VarArgType}; + +/// Scanner for finding @Class.register_* decorators in Python packages using AST parsing +pub struct DecoratorScanner { + /// Package root directory + package_root: PathBuf, +} + +impl DecoratorScanner { + /// Create a new decorator scanner for the given package root + pub fn new(package_root: PathBuf) -> Self { + debug!("Initializing decorator scanner for: {:?}", package_root); + DecoratorScanner { package_root } + } + + /// Scan the entire package for decorated functions + pub fn scan_for_decorators(&self) -> Result> { + debug!("Scanning package for decorators: {:?}", self.package_root); + + let mut registrations = Vec::new(); + let mut py_files_scanned = 0; + + // Walk through all Python files + for entry in WalkDir::new(&self.package_root) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = entry.path(); + + // Only process Python files + if path.extension().and_then(|s| s.to_str()) != Some("py") { + continue; + } + + py_files_scanned += 1; + debug!("Scanning Python file: {:?}", path); + + // Scan this file for decorators + match self.scan_file(path) { + Ok(mut file_registrations) => { + registrations.append(&mut file_registrations); + } + Err(e) => { + debug!("Error scanning file {:?}: {}", path, e); + } + } + } + + info!( + "Scanned {} Python files, found {} decorator registrations", + py_files_scanned, + registrations.len() + ); + + Ok(registrations) + } + + /// Scan a single Python file for decorators using AST parsing with ast-grep + fn scan_file(&self, file_path: &Path) -> Result> { + let content = fs::read_to_string(file_path)?; + + // Use pure ast-grep for decorator discovery + self.scan_file_with_ast_grep(file_path, &content) + } + + /// Scan file using ast-grep for pure AST-based decorator discovery + fn scan_file_with_ast_grep( + &self, + file_path: &Path, + content: &str, + ) -> Result> { + let mut registrations = Vec::new(); + + // Parse the Python code using ast-grep + let sg = AstGrep::new(content, Python); + let root = sg.root(); + + // Find all decorated functions matching the pattern: + // @$CLASS.$METHOD($$$ARGS) + // def $FUNC($$$PARAMS): $$$BODY + let pattern = "@$CLASS.$METHOD($$$ARGS)\ndef $FUNC($$$PARAMS): $$$BODY"; + + debug!( + "Searching for decorated functions with pattern: {:?}", + pattern + ); + + let decorated_functions: Vec<_> = root.find_all(pattern).collect(); + + debug!("Found {} decorated functions", decorated_functions.len()); + + for decorated_match in decorated_functions { + match self.extract_from_decorated_match(&decorated_match, file_path) { + Ok(registration) => registrations.push(registration), + Err(e) => { + debug!("extract_from_decorated_match error: {}", e); + } + } + } + + Ok(registrations) + } + + /// Extract decorator and function information directly from ast-grep match meta-variables + fn extract_from_decorated_match<'a>( + &self, + decorated_match: &ast_grep_core::matcher::NodeMatch< + 'a, + ast_grep_core::source::StrDoc, + >, + file_path: &Path, + ) -> Result { + // Extract meta-variables directly from ast-grep match using the MetaVarEnv + let env = decorated_match.get_env(); + let decorated_text = decorated_match.text(); + let decorator_line = decorated_text.lines().next().unwrap_or_default().trim(); + let function_line = decorated_text.lines().nth(1).unwrap_or_default().trim(); + + let args_text_env = env + .get_multiple_matches("$$$ARGS") + .first() + .map(|n| n.text().to_string()) + .unwrap_or_default(); + + let args_text = if args_text_env.is_empty() { + Self::extract_args_from_decorator_line(decorator_line) + } else { + args_text_env + }; + + let (class_name, method_name) = if let (Some(class), Some(method)) = + (env.get_match("$CLASS"), env.get_match("$METHOD")) + { + (class.text().to_string(), method.text().to_string()) + } else if let Some((class, method)) = Self::parse_decorator_target(decorator_line) { + (class, method) + } else { + return Err(anyhow!("Missing decorator class/method meta-variables")); + }; + + let function_name = if let Some(func) = env.get_match("$FUNC") { + func.text().to_string() + } else if let Some(name) = Self::extract_function_name_from_line(function_line) { + name + } else { + return Err(anyhow!( + "Missing $FUNC meta-variable in function definition" + )); + }; + + let params_text_env = env + .get_multiple_matches("$$$PARAMS") + .first() + .map(|n| n.text().to_string()) + .unwrap_or_default(); + + let params_text = if params_text_env.is_empty() { + Self::extract_params_from_function_line(function_line) + } else { + params_text_env + }; + + debug!( + "Found decorator @{}.{}() from ast-grep meta-variables", + class_name, method_name + ); + debug!("Found function: {}", function_name); + + // Parse decorator arguments from extracted text without regex + let decorator_args = Self::parse_decorator_args_from_text(&args_text); + + // Parse function parameters from extracted text without regex + let parameters = Self::parse_function_parameters_from_text(¶ms_text); + + // Get relative path from package root + let source_file = file_path + .strip_prefix(&self.package_root) + .ok() + .and_then(|p| p.to_str()) + .map(|s| s.to_string()); + + // Infer module name from file path + let function_module = self.infer_module_from_path(file_path); + + let function_sig = FunctionSignature { + return_type: "None".to_string(), + parameters, + }; + + Ok(DecoratorRegistration { + decorator_class: class_name, + decorator_method: method_name, + function_name, + function_module, + source_file, + line_number: None, // ast-grep doesn't provide line numbers in this context + decorator_args, + function_signature: Some(function_sig), + }) + } + + fn parse_decorator_target(decorator_line: &str) -> Option<(String, String)> { + if let Some(stripped) = decorator_line.strip_prefix('@') { + if let Some((class, rest)) = stripped.split_once('.') { + if let Some((method, _)) = rest.split_once('(') { + return Some((class.to_string(), method.to_string())); + } + } + } + None + } + + /// Parse decorator arguments like target_version=X, priority=30 without regex + pub fn parse_decorator_args_from_text(args_str: &str) -> toml::Table { + let mut table = toml::Table::new(); + + // Manual parsing of key=value pairs without regex + let mut current_arg = String::new(); + let mut depth = 0; + + for ch in args_str.chars() { + match ch { + '[' | '{' | '(' => { + depth += 1; + current_arg.push(ch); + } + ']' | '}' | ')' => { + depth -= 1; + current_arg.push(ch); + } + ',' if depth == 0 => { + if !current_arg.trim().is_empty() { + Self::parse_single_decorator_arg(¤t_arg, &mut table); + } + current_arg.clear(); + } + _ => current_arg.push(ch), + } + } + + // Don't forget the last argument + if !current_arg.trim().is_empty() { + Self::parse_single_decorator_arg(¤t_arg, &mut table); + } + + table + } + + fn extract_args_from_decorator_line(line: &str) -> String { + if let Some(start) = line.find('(') { + if let Some(end) = line.rfind(')') { + return line[start + 1..end].trim().to_string(); + } + } + String::new() + } + + fn extract_params_from_function_line(line: &str) -> String { + if let Some(start) = line.find('(') { + if let Some(end) = line.find(')') { + return line[start + 1..end].trim().to_string(); + } + } + String::new() + } + + fn extract_function_name_from_line(line: &str) -> Option { + if let Some(stripped) = line.strip_prefix("def ") { + if let Some((name, _)) = stripped.split_once('(') { + return Some(name.trim().to_string()); + } + } + None + } + + /// Parse a single decorator argument like key=value + fn parse_single_decorator_arg(arg: &str, table: &mut toml::Table) { + let arg = arg.trim(); + if let Some(eq_idx) = arg.find('=') { + let key = arg[..eq_idx].trim(); + let value_str = arg[eq_idx + 1..].trim(); + + // Try to parse value as different types + let toml_value = if let Ok(num) = value_str.parse::() { + toml::Value::Integer(num) + } else if let Ok(float) = value_str.parse::() { + toml::Value::Float(float) + } else if value_str == "True" || value_str == "False" { + toml::Value::Boolean(value_str == "True") + } else { + // String value - remove quotes + let clean = value_str + .trim_matches(|c| c == '"' || c == '\'' || c == ' ') + .to_string(); + toml::Value::String(clean) + }; + + table.insert(key.to_string(), toml_value); + } + } + + /// Parse function parameters from signature string without regex + pub fn parse_function_parameters_from_text(params_str: &str) -> Vec { + let mut params = Vec::new(); + + // Split by comma, but be careful about nested brackets + let mut current = String::new(); + let mut depth = 0; + + for ch in params_str.chars() { + match ch { + '[' | '{' | '(' => { + depth += 1; + current.push(ch); + } + ']' | '}' | ')' => { + depth -= 1; + current.push(ch); + } + ',' if depth == 0 => { + if !current.trim().is_empty() { + if let Ok(param) = Self::parse_single_parameter_from_text(¤t) { + params.push(param); + } + } + current.clear(); + } + _ => current.push(ch), + } + } + + // Don't forget the last parameter + if !current.trim().is_empty() { + if let Ok(param) = Self::parse_single_parameter_from_text(¤t) { + params.push(param); + } + } + + params + } + + /// Parse a single parameter from a parameter string without regex + fn parse_single_parameter_from_text(param_str: &str) -> Result { + let param_str = param_str.trim(); + + // Check for *args or **kwargs + let is_var_arg = if param_str.starts_with("**") { + Some(VarArgType::Kwargs) + } else if param_str.starts_with("*") { + Some(VarArgType::Args) + } else { + None + }; + + let param_str = if is_var_arg.is_some() { + ¶m_str[2..] // Remove ** or * + } else { + param_str + }; + + // Split by colon for type annotation + let (name, rest) = if let Some(colon_idx) = param_str.find(':') { + ( + param_str[..colon_idx].trim().to_string(), + Some(¶m_str[colon_idx + 1..]), + ) + } else { + (param_str.to_string(), None) + }; + + let (param_type, default) = if let Some(rest) = rest { + // Has type annotation + if let Some(eq_idx) = rest.find('=') { + let typ = rest[..eq_idx].trim().to_string(); + let def = rest[eq_idx + 1..].trim().to_string(); + (typ, Some(def)) + } else { + (rest.trim().to_string(), None) + } + } else { + // No type annotation, check for default + if let Some(eq_idx) = param_str.find('=') { + let typ = "Any".to_string(); // Default type when not specified + let def = param_str[eq_idx + 1..].trim().to_string(); + (typ, Some(def)) + } else { + ("Any".to_string(), None) + } + }; + + Ok(FunctionParameter { + name, + param_type, + default, + is_keyword_only: false, // Would need additional logic to detect this + is_var_arg, + }) + } + + /// Infer module name from file path + fn infer_module_from_path(&self, file_path: &Path) -> String { + // Get relative path from package root + if let Ok(rel_path) = file_path.strip_prefix(&self.package_root) { + // Convert file path to module path + // /path/to/package/module/submodule/file.py -> module.submodule.file + let parts: Vec<&str> = rel_path + .components() + .filter_map(|c| c.as_os_str().to_str()) + .collect(); + + if !parts.is_empty() { + let module_parts: Vec<&str> = parts + .into_iter() + .map(|p| p.strip_suffix(".py").unwrap_or(p)) + .collect(); + + return module_parts.join("."); + } + } + + "unknown".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_decorator_args_from_text() { + let args_str = "target_version=LATEST_COMMIT, upgrade_type=FILE, priority=30"; + let table = DecoratorScanner::parse_decorator_args_from_text(args_str); + + assert!(table.get("priority").is_some()); + if let Some(toml::Value::Integer(30)) = table.get("priority") { + // Good + } else { + panic!("priority should be 30"); + } + } + + #[test] + fn test_parse_function_parameters_from_text() { + let params_str = "folder: Path, upgrader_context: dict[str, Any] | None = None"; + let params = DecoratorScanner::parse_function_parameters_from_text(params_str); + + assert_eq!(params.len(), 2); + assert_eq!(params[0].name, "folder"); + assert_eq!(params[0].param_type, "Path"); + assert_eq!(params[1].name, "upgrader_context"); + } + + #[test] + fn test_parse_single_parameter_from_text() { + let param = DecoratorScanner::parse_single_parameter_from_text("folder: Path").unwrap(); + assert_eq!(param.name, "folder"); + assert_eq!(param.param_type, "Path"); + assert_eq!(param.default, None); + + let param_with_default = + DecoratorScanner::parse_single_parameter_from_text("timeout: int = 30").unwrap(); + assert_eq!(param_with_default.name, "timeout"); + assert_eq!(param_with_default.param_type, "int"); + assert_eq!(param_with_default.default, Some("30".to_string())); + } + + #[test] + fn test_infer_module_from_path() { + let scanner = DecoratorScanner::new(PathBuf::from("/Users/dev/r2x-reeds")); + let file_path = PathBuf::from("/Users/dev/r2x-reeds/r2x_reeds/upgrader.py"); + + let module = scanner.infer_module_from_path(&file_path); + assert_eq!(module, "r2x_reeds.upgrader"); + } + + #[test] + fn test_ast_grep_decorator_discovery() -> Result<()> { + use std::fs; + use tempfile::TempDir; + + // Simplified test with just the decorated function (no class context) + let content = r#"@ReEDSUpgrader.register_step(target_version="LATEST_COMMIT", priority=30) +def move_hmap_file(folder, upgrader_context=None): + pass +"#; + + // Create a proper temporary directory and .py file + let temp_dir = TempDir::new()?; + let py_file_path = temp_dir.path().join("test_decorator.py"); + fs::write(&py_file_path, content)?; + + let scanner = DecoratorScanner::new(temp_dir.path().to_path_buf()); + + let mut registrations = scanner.scan_for_decorators()?; + + if registrations.is_empty() { + let fallback = scanner.scan_file(&py_file_path)?; + assert!( + !fallback.is_empty(), + "Expected to find decorated functions via direct scan" + ); + registrations = fallback; + } + + // Check the first one + if let Some(reg) = registrations.first() { + assert_eq!(reg.decorator_class, "ReEDSUpgrader"); + assert_eq!(reg.decorator_method, "register_step"); + assert_eq!(reg.function_name, "move_hmap_file"); + } + + Ok(()) + } +} diff --git a/crates/r2x-ast/src/extractor/args.rs b/crates/r2x-ast/src/extractor/args.rs new file mode 100644 index 0000000..28fec53 --- /dev/null +++ b/crates/r2x-ast/src/extractor/args.rs @@ -0,0 +1,123 @@ +use super::*; + +impl PluginExtractor { + pub(super) fn extract_keyword_arguments_from_text( + &self, + call_text: &str, + ) -> Result> { + let mut args = Vec::new(); + + if let Some(start) = call_text.find('(') { + if let Some(end) = call_text.rfind(')') { + let args_str = &call_text[start + 1..end]; + + for arg in args_str.split(',') { + let arg = arg.trim(); + if arg.is_empty() { + continue; + } + + if let Some(eq_idx) = arg.find('=') { + let key = arg[..eq_idx].trim().to_string(); + let value_str = arg[eq_idx + 1..].trim(); + let arg_type = self.infer_argument_type(value_str); + let value = if arg_type == "string" { + value_str + .trim_matches(|c: char| c == '"' || c == '\'') + .to_string() + } else { + value_str.to_string() + }; + + args.push(ConstructorArg { + name: key.clone(), + value: value.clone(), + arg_type: arg_type.clone(), + }); + + debug!("Extracted arg: {} = {} (type: {})", key, value, arg_type); + } + } + } + } + + Ok(args) + } + + #[allow(dead_code)] + pub(super) fn extract_keyword_arguments<'r, D: ast_grep_core::Doc>( + &self, + call_node: &ast_grep_core::Node<'r, D>, + ) -> Result> { + let mut args = Vec::new(); + + for arg_match in call_node.find_all("$_") { + let arg_text = arg_match.text(); + if arg_text.contains('=') && !arg_text.contains('(') { + if let Some(eq_idx) = arg_text.find('=') { + let param_name = arg_text[..eq_idx].trim().to_string(); + let param_value = arg_text[eq_idx + 1..].trim().to_string(); + let arg_type = self.infer_argument_type(¶m_value); + + args.push(ConstructorArg { + name: param_name, + value: param_value, + arg_type, + }); + + debug!("Extracted kwarg via ast-grep"); + } + } + } + + Ok(args) + } + + pub(super) fn infer_argument_type(&self, value_str: &str) -> String { + let value_str = value_str.trim(); + + if (value_str.starts_with('"') && value_str.ends_with('"')) + || (value_str.starts_with('\'') && value_str.ends_with('\'')) + { + return "string".to_string(); + } + + if value_str.parse::().is_ok() { + return "number".to_string(); + } + + if value_str.parse::().is_ok() { + return "float".to_string(); + } + + if value_str == "True" || value_str == "False" { + return "boolean".to_string(); + } + + if value_str.contains('.') + && value_str + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '.') + { + return "enum_value".to_string(); + } + + if value_str.chars().next().map_or(false, |c| c.is_uppercase()) { + return "class_reference".to_string(); + } + + if value_str.starts_with('[') || value_str.starts_with('{') { + return "complex".to_string(); + } + + "identifier".to_string() + } + + pub(super) fn find_kwarg_value(&self, kwargs: &[ConstructorArg], name: &str) -> Result { + kwargs + .iter() + .find(|arg| arg.name == name) + .map(|arg| arg.value.clone()) + .ok_or_else(|| anyhow!("Argument '{}' not found", name)) + } +} diff --git a/crates/r2x-ast/src/extractor/mod.rs b/crates/r2x-ast/src/extractor/mod.rs new file mode 100644 index 0000000..7c8efd8 --- /dev/null +++ b/crates/r2x-ast/src/extractor/mod.rs @@ -0,0 +1,190 @@ +use anyhow::{anyhow, Result}; +use ast_grep_core::AstGrep; +use ast_grep_language::Python; +use r2x_manifest::{ConstructorArg, DiscoveryPlugin}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use tracing::{debug, info}; + +mod args; +mod parameters; +mod resolver; + +#[cfg(test)] +mod tests; + +/// Extract plugin definitions from Python source code using pure AST parsing +pub struct PluginExtractor { + pub(crate) python_file_path: PathBuf, + pub(crate) content: String, + pub(crate) import_map: HashMap, +} + +impl PluginExtractor { + /// Create a new extractor for a Python file + pub fn new(python_file_path: PathBuf) -> Result { + debug!("Initializing plugin extractor for: {:?}", python_file_path); + + let content = fs::read_to_string(&python_file_path)?; + if !content.contains("def register_plugin") { + return Err(anyhow!( + "No register_plugin function found in: {:?}", + python_file_path + )); + } + + let import_map = PluginExtractor::build_import_map_static(&content); + + Ok(PluginExtractor { + python_file_path, + content, + import_map, + }) + } + + /// Extract all plugins from the register_plugin() function using pure AST parsing + pub fn extract_plugins(&self) -> Result> { + debug!( + "Extracting plugins via pure AST parsing from: {:?}", + self.python_file_path + ); + + let sg = AstGrep::new(&self.content, Python); + let root = sg.root(); + let package_calls: Vec<_> = root.find_all("Package($$$_)").collect(); + + if package_calls.is_empty() { + return Err(anyhow!("No Package() call found")); + } + + debug!("Found {} Package() calls", package_calls.len()); + let mut plugins = Vec::new(); + + for package_match in package_calls { + let package_text = package_match.text(); + if !package_text.contains("plugins") { + debug!("Package() call doesn't have plugins parameter, skipping"); + continue; + } + + if let Some(plugins_start) = package_text.find("plugins") { + let after_plugins = &package_text[plugins_start..]; + if let Some(bracket_pos) = after_plugins.find(|c| c == '[' || c == '(') { + let bracket_char = if after_plugins.chars().nth(bracket_pos) == Some('[') { + ('[', ']') + } else { + ('(', ')') + }; + + let mut depth = 0; + let mut plugins_content_end = bracket_pos; + for (i, ch) in after_plugins.chars().enumerate().skip(bracket_pos) { + if ch == bracket_char.0 { + depth += 1; + } else if ch == bracket_char.1 { + depth -= 1; + if depth == 0 { + plugins_content_end = i; + break; + } + } + } + + let plugins_list = &after_plugins[bracket_pos + 1..plugins_content_end]; + let sg_list = AstGrep::new(plugins_list, Python); + let list_root = sg_list.root(); + let all_calls: Vec<_> = list_root.find_all("$FUNC($$$ARGS)").collect(); + + for call_match in all_calls { + let call_text = call_match.text().to_string(); + if let Ok(plugin) = self.extract_plugin_from_call_match(&call_text) { + debug!("Extracted plugin: {}", plugin.name); + plugins.push(plugin); + } + } + } + } + } + + info!("Extracted {} plugins from register_plugin()", plugins.len()); + Ok(plugins) + } + + fn extract_plugin_from_call_match(&self, call_text: &str) -> Result { + debug!( + "Parsing plugin instantiation from call match: {:?}", + call_text.lines().next() + ); + + let plugin_type = call_text + .split('(') + .next() + .ok_or_else(|| anyhow!("Cannot extract function name from call"))? + .trim() + .to_string(); + + debug!("Detected plugin type: {}", plugin_type); + + let constructor_args = self.extract_keyword_arguments_from_text(call_text)?; + let plugin_name = self.find_kwarg_value(&constructor_args, "name")?; + + Ok(DiscoveryPlugin { + name: plugin_name, + plugin_type, + constructor_args, + resolved_references: Vec::new(), + decorators: Vec::new(), + }) + } + + fn build_import_map_static(content: &str) -> HashMap { + let mut map = HashMap::new(); + let register_plugin_start = if let Some(pos) = content.find("def register_plugin") { + pos + } else { + 0 + }; + + let content_to_scan = &content[register_plugin_start..]; + + for line in content_to_scan.lines() { + let line = line.trim(); + if line.starts_with('#') { + continue; + } + + if line.starts_with("from ") && line.contains(" import ") { + if let Some(import_idx) = line.find(" import ") { + let module = line[5..import_idx].trim(); + let imports_part = line[import_idx + 8..].trim(); + + for import_item in imports_part.split(',') { + let import_item = import_item.trim(); + if import_item.ends_with('\\') || import_item.is_empty() { + continue; + } + + let class_name = if let Some(as_idx) = import_item.find(" as ") { + import_item[as_idx + 4..].trim() + } else { + import_item + }; + + let class_name = class_name + .trim_matches(|c| c == '(' || c == ')' || c == ',') + .trim(); + + if !class_name.is_empty() && !class_name.starts_with('#') { + map.insert(class_name.to_string(), module.to_string()); + debug!("Mapped class {} to module {}", class_name, module); + } + } + } + } + } + + debug!("Built import map with {} entries", map.len()); + map + } +} diff --git a/crates/r2x-ast/src/extractor/parameters.rs b/crates/r2x-ast/src/extractor/parameters.rs new file mode 100644 index 0000000..550a18c --- /dev/null +++ b/crates/r2x-ast/src/extractor/parameters.rs @@ -0,0 +1,243 @@ +use super::*; +use r2x_manifest::types::ParameterEntry; + +impl PluginExtractor { + pub(super) fn extract_class_parameters_from_content( + &self, + content: &str, + class_name: &str, + ) -> Result> { + if let Some(init_signature) = self.find_init_signature(content, class_name) { + let params = self.parse_parameters_to_entries(&init_signature); + if !params.is_empty() { + return Ok(params); + } + } + + if let Some(class_signature) = self.find_class_signature(content, class_name) { + return Ok(self.parse_parameters_to_entries(&class_signature)); + } + + if self.class_exists(content, class_name) { + return Ok(Vec::new()); + } + + Err(anyhow!("Class not found: {}", class_name)) + } + + pub(super) fn extract_function_parameters_from_content( + &self, + content: &str, + function_name: &str, + ) -> Result> { + if let Some(function_signature) = self.find_function_signature(content, function_name) { + return Ok(self.parse_parameters_to_entries(&function_signature)); + } + + if self.function_exists(content, function_name) { + return Ok(Vec::new()); + } + + Err(anyhow!("Function not found: {}", function_name)) + } + + pub(super) fn extract_function_return_type_from_content( + &self, + content: &str, + function_name: &str, + ) -> Option { + let func_text = self.find_function_signature(content, function_name)?; + + let arrow_pos = func_text.find("->")?; + let colon_pos = func_text[arrow_pos..].find(':')?; + Some( + func_text[arrow_pos + 2..arrow_pos + colon_pos] + .trim() + .to_string(), + ) + } + + fn find_class_signature(&self, content: &str, class_name: &str) -> Option { + let sg = AstGrep::new(content, Python); + let root = sg.root(); + let pattern = format!("class {}(", class_name); + let mut matches = root.find_all(pattern.as_str()); + matches.next().map(|m| m.text().to_string()) + } + + fn find_function_signature(&self, content: &str, function_name: &str) -> Option { + let sg = AstGrep::new(content, Python); + let root = sg.root(); + let pattern = format!("def {}(", function_name); + let mut matches = root.find_all(pattern.as_str()); + matches.next().map(|m| m.text().to_string()) + } + + fn find_init_signature(&self, content: &str, class_name: &str) -> Option { + let lines: Vec<&str> = content.lines().collect(); + let mut in_target_class = false; + let mut class_indent = 0usize; + let mut idx = 0; + + while idx < lines.len() { + let line = lines[idx]; + let trimmed = line.trim_start(); + + if !in_target_class { + if let Some(rest) = trimmed.strip_prefix("class ") { + if rest.starts_with(class_name) { + let after_name = &rest[class_name.len()..]; + if after_name.starts_with('(') || after_name.starts_with(':') { + in_target_class = true; + class_indent = line.chars().take_while(|c| c.is_whitespace()).count(); + } + } + } + idx += 1; + continue; + } + + if trimmed.is_empty() { + idx += 1; + continue; + } + + let indent = line.chars().take_while(|c| c.is_whitespace()).count(); + if indent <= class_indent && !trimmed.starts_with('#') { + break; + } + + if trimmed.starts_with("def __init__") { + let mut signature = trimmed.to_string(); + let mut inner_idx = idx + 1; + while !signature.contains("):") + && !signature.contains(")->") + && inner_idx < lines.len() + { + let continuation = lines[inner_idx].trim(); + signature.push(' '); + signature.push_str(continuation); + inner_idx += 1; + } + return Some(signature); + } + + idx += 1; + } + + None + } + + fn class_exists(&self, content: &str, class_name: &str) -> bool { + content.contains(&format!("class {}", class_name)) + } + + fn function_exists(&self, content: &str, function_name: &str) -> bool { + content.contains(&format!("def {}", function_name)) + } + + fn parse_parameters_to_entries(&self, func_text: &str) -> Vec { + let mut parameters = Vec::new(); + + let Some(start) = func_text.find('(') else { + return parameters; + }; + let Some(end) = func_text[start..].find(')') else { + return parameters; + }; + + let params_str = &func_text[start + 1..start + end]; + let mut current_param = String::new(); + let mut depth = 0; + + for ch in params_str.chars() { + match ch { + '[' | '(' | '{' => { + depth += 1; + current_param.push(ch); + } + ']' | ')' | '}' => { + depth -= 1; + current_param.push(ch); + } + ',' if depth == 0 => { + if let Some(entry) = self.parse_single_parameter_entry(¤t_param) { + parameters.push(entry); + } + current_param.clear(); + } + _ => current_param.push(ch), + } + } + + if let Some(entry) = self.parse_single_parameter_entry(¤t_param) { + parameters.push(entry); + } + + parameters + } + + fn parse_single_parameter_entry(&self, raw: &str) -> Option { + let param_str = raw.trim(); + + if param_str.is_empty() + || param_str == "self" + || param_str == "/" + || param_str.starts_with('*') + { + return None; + } + + let (name_part, rest) = if let Some(colon_idx) = param_str.find(':') { + ( + param_str[..colon_idx].trim(), + Some(param_str[colon_idx + 1..].trim()), + ) + } else { + (param_str, None) + }; + + let name = name_part.to_string(); + if name.is_empty() { + return None; + } + + let (annotation, default, is_required) = match rest { + Some(rest) if rest.contains('=') => { + let eq_idx = rest.find('=').unwrap(); + let annotation = rest[..eq_idx].trim(); + let default = rest[eq_idx + 1..].trim(); + ( + (!annotation.is_empty()).then(|| annotation.to_string()), + (!default.is_empty()).then(|| default.to_string()), + false, + ) + } + Some(rest) => { + let annotation = rest.trim(); + ( + (!annotation.is_empty()).then(|| annotation.to_string()), + None, + true, + ) + } + None if param_str.contains('=') => { + let eq_idx = param_str.find('=').unwrap(); + let default = param_str[eq_idx + 1..].trim(); + ( + None, + (!default.is_empty()).then(|| default.to_string()), + false, + ) + } + None => (None, None, true), + }; + + Some(ParameterEntry { + name, + annotation, + default, + is_required, + }) + } +} diff --git a/crates/r2x-ast/src/extractor/resolver.rs b/crates/r2x-ast/src/extractor/resolver.rs new file mode 100644 index 0000000..237545a --- /dev/null +++ b/crates/r2x-ast/src/extractor/resolver.rs @@ -0,0 +1,393 @@ +use super::*; +use r2x_manifest::types::{ParameterEntry, ResolvedReference}; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +#[derive(Clone, Copy)] +enum ReferenceKind { + Class, + Function, +} + +impl ReferenceKind { + fn as_str(&self) -> &'static str { + match self { + ReferenceKind::Class => "class", + ReferenceKind::Function => "function", + } + } +} + +struct DefinitionMatch { + kind: ReferenceKind, + module_path: String, + file_path: PathBuf, + parameters: Vec, + return_annotation: Option, +} + +impl PluginExtractor { + pub fn resolve_references( + &self, + plugin: &mut DiscoveryPlugin, + package_root: &Path, + package_name: &str, + ) -> Result<()> { + let mut resolved_refs = Vec::new(); + + for arg in &plugin.constructor_args { + let prefer_class = arg.arg_type == "class_reference" + || (arg.arg_type == "identifier" + && arg + .value + .chars() + .next() + .map(|c| c.is_uppercase()) + .unwrap_or(false)); + + if arg.arg_type == "class_reference" || arg.arg_type == "identifier" { + match self.resolve_single_reference( + &arg.value, + package_root, + package_name, + prefer_class, + ) { + Ok(resolved) => { + resolved_refs.push(resolved); + } + Err(e) => { + debug!( + "Failed to resolve reference {} for plugin {}: {}", + arg.value, plugin.name, e + ); + } + } + } + } + + plugin.resolved_references = resolved_refs; + Ok(()) + } + + fn resolve_single_reference( + &self, + reference: &str, + package_root: &Path, + package_name: &str, + prefer_class: bool, + ) -> Result { + let definition = + self.locate_definition(reference, package_root, package_name, prefer_class)?; + + let source_file = self + .relative_source_path(&definition.file_path, package_root) + .or_else(|| Some(definition.file_path.to_string_lossy().to_string())); + + Ok(ResolvedReference { + key: reference.to_string(), + ref_type: definition.kind.as_str().to_string(), + module: definition.module_path, + name: reference.to_string(), + source_file, + parameters: definition.parameters, + return_annotation: definition.return_annotation, + }) + } + + fn locate_definition( + &self, + reference: &str, + package_root: &Path, + package_name: &str, + prefer_class: bool, + ) -> Result { + if let Some(def) = self.try_locate_in_file( + reference, + prefer_class, + &self.python_file_path, + &self.content, + package_root, + package_name, + ) { + return Ok(def); + } + + if let Some(module_hint) = self.import_map.get(reference) { + if let Some(def) = self.locate_via_import( + reference, + module_hint, + prefer_class, + package_root, + package_name, + ) { + return Ok(def); + } + } + + self.scan_package_for_definition(reference, package_root, package_name, prefer_class) + } + + fn try_locate_in_file( + &self, + reference: &str, + prefer_class: bool, + file_path: &Path, + content: &str, + package_root: &Path, + package_name: &str, + ) -> Option { + let module_path = self.build_module_path(file_path, package_root, package_name); + self.try_parse_definition(reference, prefer_class, content, file_path, module_path) + } + + fn try_parse_definition( + &self, + reference: &str, + prefer_class: bool, + content: &str, + file_path: &Path, + module_path: String, + ) -> Option { + let mut attempts = if prefer_class { + vec![ReferenceKind::Class, ReferenceKind::Function] + } else { + vec![ReferenceKind::Function, ReferenceKind::Class] + }; + + for kind in attempts.drain(..) { + match kind { + ReferenceKind::Class => { + if let Ok(parameters) = + self.extract_class_parameters_from_content(content, reference) + { + return Some(DefinitionMatch { + kind, + module_path: module_path.clone(), + file_path: file_path.to_path_buf(), + parameters, + return_annotation: None, + }); + } + } + ReferenceKind::Function => { + if let Ok(parameters) = + self.extract_function_parameters_from_content(content, reference) + { + let return_annotation = + self.extract_function_return_type_from_content(content, reference); + return Some(DefinitionMatch { + kind, + module_path: module_path.clone(), + file_path: file_path.to_path_buf(), + parameters, + return_annotation, + }); + } + } + } + } + + // As a final fallback, try the opposite order if nothing matched. + None + } + + fn locate_via_import( + &self, + reference: &str, + module_hint: &str, + prefer_class: bool, + package_root: &Path, + package_name: &str, + ) -> Option { + let resolved_module = self.resolve_module_hint(module_hint, package_root, package_name); + + for candidate in self + .module_to_candidate_paths(&resolved_module, package_root) + .into_iter() + { + if let Ok(content) = fs::read_to_string(&candidate) { + if let Some(def) = self.try_parse_definition( + reference, + prefer_class, + &content, + &candidate, + resolved_module.clone(), + ) { + return Some(def); + } + } + } + + None + } + + fn scan_package_for_definition( + &self, + reference: &str, + package_root: &Path, + package_name: &str, + prefer_class: bool, + ) -> Result { + for entry in WalkDir::new(package_root) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("py") { + continue; + } + if path == self.python_file_path { + continue; + } + + if let Ok(content) = fs::read_to_string(path) { + if let Some(def) = self.try_locate_in_file( + reference, + prefer_class, + path, + &content, + package_root, + package_name, + ) { + return Ok(def); + } + } + } + + Err(anyhow!("Unable to resolve reference '{}'", reference)) + } + + fn build_module_path( + &self, + file_path: &Path, + package_root: &Path, + package_name: &str, + ) -> String { + let package_prefix = package_name.replace('-', "_"); + if let Ok(relative) = file_path.strip_prefix(package_root) { + let mut module_path = relative.to_string_lossy().to_string(); + module_path = module_path + .trim_end_matches(".py") + .replace('\\', ".") + .replace('/', "."); + + if module_path.ends_with(".__init__") { + module_path = module_path + .trim_end_matches(".__init__") + .trim_end_matches('.') + .to_string(); + } + + module_path = module_path.trim_matches('.').to_string(); + + if module_path.is_empty() { + return package_prefix; + } + + if module_path.starts_with(&package_prefix) { + return module_path; + } + + if let Some(root_name) = package_root.file_name().and_then(|n| n.to_str()) { + if module_path.starts_with(root_name) { + return module_path; + } + } + + format!("{}.{}", package_prefix, module_path) + } else { + package_prefix + } + } + + fn resolve_module_hint( + &self, + module_hint: &str, + package_root: &Path, + package_name: &str, + ) -> String { + if module_hint.starts_with('.') { + let current_module = + self.build_module_path(&self.python_file_path, package_root, package_name); + self.apply_relative_module(¤t_module, module_hint) + } else { + module_hint.to_string() + } + } + + fn apply_relative_module(&self, current_module: &str, relative: &str) -> String { + let mut dot_count = 0; + for ch in relative.chars() { + if ch == '.' { + dot_count += 1; + } else { + break; + } + } + + let mut base_parts: Vec<&str> = current_module.split('.').collect(); + for _ in 0..dot_count { + base_parts.pop(); + } + + let remainder = relative.trim_start_matches('.'); + if !remainder.is_empty() { + base_parts.extend(remainder.split('.').filter(|s| !s.is_empty())); + } + + base_parts.join(".") + } + + fn module_to_candidate_paths(&self, module_path: &str, package_root: &Path) -> Vec { + let normalized = module_path.replace('.', "/"); + let mut candidates = Vec::new(); + let mut seen = HashSet::new(); + + let base_candidates = if normalized.is_empty() { + vec![package_root.join("__init__.py")] + } else { + vec![ + package_root.join(format!("{}.py", normalized)), + package_root.join(&normalized).join("__init__.py"), + ] + }; + + for path in base_candidates { + if seen.insert(path.clone()) { + candidates.push(path); + } + } + + if let Some(root_name) = package_root.file_name().and_then(|n| n.to_str()) { + if normalized.starts_with(root_name) { + let trimmed = normalized[root_name.len()..].trim_start_matches('/'); + let extra = if trimmed.is_empty() { + vec![package_root.join("__init__.py")] + } else { + vec![ + package_root.join(format!("{}.py", trimmed)), + package_root.join(trimmed).join("__init__.py"), + ] + }; + + for path in extra { + if seen.insert(path.clone()) { + candidates.push(path); + } + } + } + } + + candidates + } + + fn relative_source_path(&self, file_path: &Path, package_root: &Path) -> Option { + file_path + .strip_prefix(package_root) + .ok() + .and_then(|p| p.to_str()) + .map(|s| s.to_string()) + } +} diff --git a/crates/r2x-ast/src/extractor/tests.rs b/crates/r2x-ast/src/extractor/tests.rs new file mode 100644 index 0000000..93ed7f2 --- /dev/null +++ b/crates/r2x-ast/src/extractor/tests.rs @@ -0,0 +1,216 @@ +use super::*; +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use tempfile::{NamedTempFile, TempDir}; + +#[test] +fn test_infer_argument_type_string() { + let extractor = PluginExtractor { + python_file_path: PathBuf::from("test.py"), + content: "def register_plugin(): pass".to_string(), + import_map: HashMap::new(), + }; + + assert_eq!(extractor.infer_argument_type(r#""hello""#), "string"); + assert_eq!(extractor.infer_argument_type("'hello'"), "string"); +} + +#[test] +fn test_infer_argument_type_number() { + let extractor = PluginExtractor { + python_file_path: PathBuf::from("test.py"), + content: "def register_plugin(): pass".to_string(), + import_map: HashMap::new(), + }; + + assert_eq!(extractor.infer_argument_type("42"), "number"); + assert_eq!(extractor.infer_argument_type("3.14"), "float"); +} + +#[test] +fn test_infer_argument_type_enum() { + let extractor = PluginExtractor { + python_file_path: PathBuf::from("test.py"), + content: "def register_plugin(): pass".to_string(), + import_map: HashMap::new(), + }; + + assert_eq!(extractor.infer_argument_type("IOType.STDOUT"), "enum_value"); +} + +#[test] +fn test_infer_argument_type_class() { + let extractor = PluginExtractor { + python_file_path: PathBuf::from("test.py"), + content: "def register_plugin(): pass".to_string(), + import_map: HashMap::new(), + }; + + assert_eq!( + extractor.infer_argument_type("ReEDSParser"), + "class_reference" + ); + assert_eq!(extractor.infer_argument_type("MyClass"), "class_reference"); +} + +#[test] +fn test_extract_from_real_file_with_dynamic_types() -> Result<()> { + let content = r#" +from r2x_core.package import Package + +def register_plugin() -> Package: + return Package( + name="r2x-reeds", + plugins=[ + ParserPlugin( + name="reeds-parser", + obj=ReEDSParser, + config=ReEDSConfig, + call_method="build_system", + io_type=IOType.STDOUT, + ), + CustomUpgrader( + name="custom-upgrader", + obj=CustomClass, + ), + ] + ) +"#; + + let mut temp_file = NamedTempFile::new()?; + temp_file.write_all(content.as_bytes())?; + temp_file.flush()?; + + let extractor = PluginExtractor::new(temp_file.path().to_path_buf())?; + let plugins = extractor.extract_plugins()?; + + assert!(plugins.len() >= 1); + + Ok(()) +} + +#[test] +fn test_resolve_references_across_files() -> Result<()> { + let temp_dir = TempDir::new()?; + let package_root = temp_dir.path().join("src").join("r2x_reeds"); + fs::create_dir_all(&package_root)?; + + let plugins_py = package_root.join("plugins.py"); + let upgrader_py = package_root.join("upgrader.py"); + + let plugins_content = r#" +def register_plugin(): + from .upgrader import ReEDSUpgrader + + return Package( + name="r2x-reeds", + plugins=[ + UpgraderPlugin( + name="reeds-upgrader", + obj=ReEDSUpgrader, + ), + ], + ) +"#; + fs::write(&plugins_py, plugins_content)?; + + let upgrader_content = r#" +class ReEDSUpgrader: + def __init__(self, source_path: str, dry_run: bool = False): + self.source_path = source_path + self.dry_run = dry_run +"#; + fs::write(&upgrader_py, upgrader_content)?; + + let extractor = PluginExtractor::new(plugins_py.clone())?; + assert!(extractor.import_map.contains_key("ReEDSUpgrader")); + let mut plugins = extractor.extract_plugins()?; + assert_eq!(plugins.len(), 1); + + extractor.resolve_references(&mut plugins[0], package_root.as_path(), "r2x-reeds")?; + + assert_eq!(plugins[0].resolved_references.len(), 1); + let resolved = &plugins[0].resolved_references[0]; + assert_eq!(resolved.name, "ReEDSUpgrader"); + assert_eq!(resolved.module, "r2x_reeds.upgrader"); + assert_eq!(resolved.ref_type, "class"); + assert_eq!(resolved.parameters.len(), 2); + assert_eq!(resolved.parameters[0].name, "source_path"); + assert!(resolved + .source_file + .as_ref() + .map(|path| path.ends_with("upgrader.py")) + .unwrap_or(false)); + + Ok(()) +} + +#[test] +fn test_resolve_references_nested_module_path() -> Result<()> { + let temp_dir = TempDir::new()?; + let package_root = temp_dir.path().join("src").join("pkg_name"); + let nested_dir = package_root.join("upgrader"); + fs::create_dir_all(&nested_dir)?; + + let plugins_py = package_root.join("plugins.py"); + let plugins_content = r#" +def register_plugin(): + from pkg_name.upgrader.data_upgrader import NestedUpgrader + + return Package( + name="pkg-name", + plugins=[ + UpgraderPlugin( + name="nested-upgrader", + obj=NestedUpgrader, + ), + ], + ) +"#; + fs::write(&plugins_py, plugins_content)?; + + let nested_content = r#" +class NestedUpgrader(PluginUpgrader): + def __init__(self, level: int): + self.level = level +"#; + let nested_file = nested_dir.join("data_upgrader.py"); + fs::write(&nested_file, nested_content)?; + + let extractor = PluginExtractor::new(plugins_py.clone())?; + let mut plugins = extractor.extract_plugins()?; + assert_eq!(plugins.len(), 1); + + extractor.resolve_references(&mut plugins[0], package_root.as_path(), "pkg-name")?; + + assert_eq!(plugins[0].resolved_references.len(), 1); + let resolved = &plugins[0].resolved_references[0]; + assert_eq!(resolved.module, "pkg_name.upgrader.data_upgrader"); + + Ok(()) +} + +#[test] +fn test_extract_class_parameters_from_multiline_init() -> Result<()> { + let content = r#" +class SampleUpgrader(PluginUpgrader): + def __init__( + self, + path: Path | str, + steps: list[UpgradeStep] | None = None, + **kwargs: Any, + ) -> None: + pass +"#; + + let extractor = PluginExtractor { + python_file_path: PathBuf::from("test.py"), + content: content.to_string(), + import_map: HashMap::new(), + }; + + let params = extractor.extract_class_parameters_from_content(content, "SampleUpgrader")?; + assert!(params.iter().any(|p| p.name == "path")); + Ok(()) +} diff --git a/crates/r2x-ast/src/lib.rs b/crates/r2x-ast/src/lib.rs new file mode 100644 index 0000000..0aaa592 --- /dev/null +++ b/crates/r2x-ast/src/lib.rs @@ -0,0 +1,473 @@ +//! AST-based plugin discovery using ast-grep +//! +//! This module provides static analysis based plugin discovery by: +//! 1. Using ast-grep to parse Python source code without runtime (Phase 1) +//! 2. Extracting plugin definitions from the register_plugin() function +//! 3. Resolving class/function references to extract metadata (Phase 2) +//! 4. Associating decorator registrations with plugins (Phase 3) +//! +//! This approach is significantly faster than Python-based discovery and requires +//! no Python interpreter startup. +pub mod decorator_scanner; +pub mod extractor; +use anyhow::{anyhow, Result}; +use ast_grep_language::Python; +use r2x_logger as logger; +use r2x_manifest::{DecoratorRegistration, DiscoveryPlugin, FunctionSignature}; +use std::path::Path; + +/// AST-based plugin discovery orchestrator +pub struct AstDiscovery; + +impl AstDiscovery { + /// Discover plugins from a Python package using AST parsing + /// + /// # Arguments + /// * `package_path` - Path to the installed package (e.g., site-packages/r2x_reeds) + /// * `package_name_full` - Full package name (e.g., "r2x-reeds") + /// * `venv_path` - Optional path to virtual environment for entry_points.txt lookup + /// * `package_version` - Optional package version string + /// + /// # Returns + /// Tuple of (plugins with resolved references, decorator registrations) + pub fn discover_plugins( + package_path: &Path, + package_name_full: &str, + venv_path: Option<&str>, + _package_version: Option<&str>, + ) -> Result<(Vec, Vec)> { + let start_time = std::time::Instant::now(); + logger::info(&format!("AST discovery started for: {}", package_name_full)); + + // Find the plugins.py file using entry_points.txt + let plugins_py = + Self::find_plugins_py_via_entry_points(package_path, package_name_full, venv_path)?; + logger::info(&format!("Found plugins.py at: {:?}", plugins_py)); + + // Phase 1: Extract plugins with constructor_args + let extractor = extractor::PluginExtractor::new(plugins_py.clone()) + .map_err(|e| anyhow!("Failed to create extractor: {}", e))?; + + let mut plugins = extractor + .extract_plugins() + .map_err(|e| anyhow!("Failed to extract plugins: {}", e))?; + + logger::info(&format!( + "Phase 1 complete: Extracted {} plugins", + plugins.len() + )); + + // Phase 2: Resolve all class/function references + let package_root = plugins_py + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| package_path.to_path_buf()); + for plugin in &mut plugins { + extractor + .resolve_references(plugin, &package_root, package_name_full) + .map_err(|e| anyhow!("Failed to resolve references for {}: {}", plugin.name, e))?; + } + + logger::info(&format!( + "Phase 2 complete: Resolved references for {} plugins", + plugins.len() + )); + + // Phase 3: Scan for decorator registrations and associate with plugins + let decorator_registrations = Self::scan_package_for_decorators(&plugins_py)?; + logger::info(&format!( + "Phase 3 complete: Found {} decorator registrations", + decorator_registrations.len() + )); + + // TODO: Associate decorators with plugins based on class references + + let elapsed = start_time.elapsed(); + logger::info(&format!( + "AST discovery completed in {:.2}ms for {}", + elapsed.as_secs_f64() * 1000.0, + package_name_full + )); + + Ok((plugins, decorator_registrations)) + } + /// Find plugins.py file using entry_points.txt + fn find_plugins_py_via_entry_points( + package_path: &Path, + package_name_full: &str, + venv_path: Option<&str>, + ) -> Result { + use std::fs; + // Try to find entry_points.txt in the package's dist-info + let entry_points_path = + Self::find_entry_points_txt(package_path, package_name_full, venv_path)?; + // Parse the entry_points.txt to get the module path + let entry_points_content = fs::read_to_string(&entry_points_path) + .map_err(|e| anyhow!("Failed to read entry_points.txt: {}", e))?; + // Look for [r2x_plugin] section and extract module:function + let (module_path, _function) = Self::parse_entry_point(&entry_points_content)?; + // Convert module path to file path + // e.g., "r2x_reeds.plugins" -> "r2x_reeds/plugins.py" + let relative_path = module_path.replace('.', "/") + ".py"; + // For editable installs, package_path points to src/ or similar + // Try to locate the actual file + let plugins_path = package_path.join(&relative_path); + if plugins_path.exists() { + return Ok(plugins_path); + } + // Try one level up (in case package_path is the package root) + if let Some(parent) = package_path.parent() { + let plugins_path = parent.join(&relative_path); + if plugins_path.exists() { + return Ok(plugins_path); + } + } + Err(anyhow!( + "Could not find plugins.py at expected location: {} (from entry point: {})", + relative_path, + module_path + )) + } + /// Find entry_points.txt for the package + fn find_entry_points_txt( + package_path: &Path, + package_name_full: &str, + venv_path: Option<&str>, + ) -> Result { + use std::fs; + let normalized_name = package_name_full.replace('-', "_"); + // Try venv site-packages first if provided + if let Some(venv) = venv_path { + let venv_path = std::path::PathBuf::from(venv); + let lib_dir = venv_path.join("lib"); + if let Ok(entries) = fs::read_dir(&lib_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() + && path + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("python") + { + let site_packages = path.join("site-packages"); + if let Ok(sp_entries) = fs::read_dir(&site_packages) { + for sp_entry in sp_entries.flatten() { + let name = sp_entry.file_name().to_string_lossy().to_string(); + if name.starts_with(&normalized_name) + && name.ends_with(".dist-info") + { + let entry_points = sp_entry.path().join("entry_points.txt"); + if entry_points.exists() { + return Ok(entry_points); + } + } + } + } + } + } + } + } + // Fallback: look near package_path + if let Some(parent) = package_path.parent() { + if let Ok(entries) = fs::read_dir(parent) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with(&normalized_name) && name.ends_with(".dist-info") { + let entry_points = entry.path().join("entry_points.txt"); + if entry_points.exists() { + return Ok(entry_points); + } + } + } + } + } + Err(anyhow!( + "Could not find entry_points.txt for package: {}", + package_name_full + )) + } + /// Parse entry_points.txt to extract r2x_plugin entry point + fn parse_entry_point(content: &str) -> Result<(String, String)> { + let mut in_r2x_section = false; + for line in content.lines() { + let line = line.trim(); + if line == "[r2x_plugin]" { + in_r2x_section = true; + continue; + } + if in_r2x_section { + if line.starts_with('[') { + break; + } + if line.is_empty() || line.starts_with('#') { + continue; + } + // Parse "key = module:function" format + if let Some(eq_idx) = line.find('=') { + let value = line[eq_idx + 1..].trim(); + if let Some(colon_idx) = value.find(':') { + let module = value[..colon_idx].trim().to_string(); + let function = value[colon_idx + 1..].trim().to_string(); + return Ok((module, function)); + } + } + } + } + Err(anyhow!( + "No [r2x_plugin] entry point found in entry_points.txt" + )) + } + /// Scan entire package directory for decorator registrations + fn scan_package_for_decorators( + plugins_py: &std::path::Path, + ) -> Result> { + use walkdir::WalkDir; + let mut all_registrations = Vec::new(); + // Get the package root directory (parent of plugins.py) + let package_root = plugins_py + .parent() + .ok_or_else(|| anyhow!("Invalid plugins.py path"))?; + logger::info(&format!( + "Scanning for decorator registrations in: {:?}", + package_root + )); + // Walk through all Python files in the package + for entry in WalkDir::new(package_root) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = entry.path(); + // Only process Python files + if path.extension().and_then(|s| s.to_str()) != Some("py") { + continue; + } + // Skip __pycache__ and other non-source files + if path.to_string_lossy().contains("__pycache__") { + continue; + } + if let Ok(content) = std::fs::read_to_string(path) { + if let Ok(registrations) = + Self::scan_file_for_decorators(path, &content, package_root) + { + if !registrations.is_empty() { + logger::info(&format!( + "Found {} decorator(s) in: {:?}", + registrations.len(), + path + )); + all_registrations.extend(registrations); + } + } + } + } + Ok(all_registrations) + } + /// Scan a single file for decorator registrations + fn scan_file_for_decorators( + file_path: &std::path::Path, + content: &str, + package_root: &std::path::Path, + ) -> Result> { + use ast_grep_core::AstGrep; + use ast_grep_language::Python; + let mut registrations = Vec::new(); + let sg = AstGrep::new(content, Python); + let root = sg.root(); + // Find all decorated functions matching the pattern: + // @$CLASS.$METHOD($$$ARGS) + // def $FUNC($$$PARAMS): $$$BODY + let pattern = "@$CLASS.$METHOD($$$ARGS) +def $FUNC($$$PARAMS): $$$BODY"; + let decorated_functions: Vec<_> = root.find_all(pattern).collect(); + for decorated_match in decorated_functions { + if let Ok(registration) = + Self::extract_decorator_info(&decorated_match, file_path, package_root) + { + registrations.push(registration); + } + } + Ok(registrations) + } + /// Extract decorator information from a match + fn extract_decorator_info( + decorated_match: &ast_grep_core::matcher::NodeMatch< + '_, + ast_grep_core::source::StrDoc, + >, + file_path: &std::path::Path, + package_root: &std::path::Path, + ) -> Result { + let env = decorated_match.get_env(); + let class_name = env + .get_match("$CLASS") + .ok_or_else(|| anyhow!("Missing $CLASS"))? + .text() + .to_string(); + let method_name = env + .get_match("$METHOD") + .ok_or_else(|| anyhow!("Missing $METHOD"))? + .text() + .to_string(); + let args_text = env + .get_multiple_matches("$$$ARGS") + .first() + .map(|n| n.text().to_string()) + .unwrap_or_default(); + let function_name = env + .get_match("$FUNC") + .ok_or_else(|| anyhow!("Missing $FUNC"))? + .text() + .to_string(); + let params_text = env + .get_multiple_matches("$$$PARAMS") + .first() + .map(|n| n.text().to_string()) + .unwrap_or_default(); + // Parse decorator arguments + let decorator_args = + decorator_scanner::DecoratorScanner::parse_decorator_args_from_text(&args_text); + // Parse function parameters + let parameters = + decorator_scanner::DecoratorScanner::parse_function_parameters_from_text(¶ms_text); + // Get relative path from package root + let source_file = file_path + .strip_prefix(package_root) + .ok() + .and_then(|p| p.to_str()) + .map(|s| s.to_string()); + // Infer module name from file path + let function_module = Self::infer_module_from_path(file_path, package_root); + let function_sig = FunctionSignature { + return_type: "None".to_string(), + parameters, + }; + Ok(DecoratorRegistration { + decorator_class: class_name, + decorator_method: method_name, + function_name, + function_module, + source_file, + line_number: None, + decorator_args, + function_signature: Some(function_sig), + }) + } + /// Infer module name from file path relative to package root + fn infer_module_from_path( + file_path: &std::path::Path, + package_root: &std::path::Path, + ) -> String { + if let Ok(rel_path) = file_path.strip_prefix(package_root) { + let parts: Vec<&str> = rel_path + .components() + .filter_map(|c| c.as_os_str().to_str()) + .collect(); + if !parts.is_empty() { + let module_parts: Vec<&str> = parts + .into_iter() + .map(|p| p.strip_suffix(".py").unwrap_or(p)) + .collect(); + // Add the package name prefix + if let Some(package_name) = package_root.file_name().and_then(|n| n.to_str()) { + return format!("{}.{}", package_name, module_parts.join(".")); + } + return module_parts.join("."); + } + } + "unknown".to_string() + } +} +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + #[test] + fn test_parse_entry_point() { + let content = r#"[r2x_plugin] +reeds = r2x_reeds.plugins:register_plugin +"#; + let result = AstDiscovery::parse_entry_point(content); + assert!(result.is_ok()); + let (module, function) = result.unwrap(); + assert_eq!(module, "r2x_reeds.plugins"); + assert_eq!(function, "register_plugin"); + } + #[test] + fn test_plugin_extraction() { + use r2x_manifest::ConstructorArg; + + let plugin = DiscoveryPlugin { + name: "test-parser".to_string(), + plugin_type: "ParserPlugin".to_string(), + constructor_args: vec![ + ConstructorArg { + name: "name".to_string(), + value: "test-parser".to_string(), + arg_type: "string".to_string(), + }, + ConstructorArg { + name: "obj".to_string(), + value: "TestParser".to_string(), + arg_type: "class_reference".to_string(), + }, + ], + resolved_references: vec![], + decorators: vec![], + }; + + assert_eq!(plugin.name, "test-parser"); + assert_eq!(plugin.plugin_type, "ParserPlugin"); + assert_eq!(plugin.constructor_args.len(), 2); + } + #[test] + fn test_discover_plugins_integration() { + let temp_dir = TempDir::new().unwrap(); + let plugins_file = temp_dir.path().join("plugins.py"); + // Create a minimal valid plugins.py file + let content = r#" +from r2x_core.package import Package +def register_plugin() -> Package: + return Package( + name="r2x-test", + plugins=[ + ParserPlugin( + name="test-parser", + obj=TestParser, + config=TestConfig, + call_method="parse", + io_type=IOType.STDOUT, + ), + ] + ) +"#; + fs::write(&plugins_file, content).unwrap(); + // This test will fail without proper entry_points.txt setup + // Just verify the file was created + assert!(plugins_file.exists()); + } + #[test] + fn test_resolved_references() { + use r2x_manifest::types::{ParameterEntry, ResolvedReference}; + + let resolved_ref = ResolvedReference { + key: "TestParser".to_string(), + ref_type: "class".to_string(), + module: "test.module".to_string(), + name: "TestParser".to_string(), + source_file: Some("test/module.py".to_string()), + parameters: vec![ParameterEntry { + name: "config".to_string(), + annotation: Some("TestConfig".to_string()), + default: None, + is_required: true, + }], + return_annotation: None, + }; + + assert_eq!(resolved_ref.ref_type, "class"); + assert_eq!(resolved_ref.module, "test.module"); + assert_eq!(resolved_ref.parameters.len(), 1); + } +} diff --git a/crates/r2x-cli/Cargo.toml b/crates/r2x-cli/Cargo.toml new file mode 100644 index 0000000..443fffd --- /dev/null +++ b/crates/r2x-cli/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "r2x" +version = { workspace = true } +edition = { workspace = true } +authors = { workspace = true } +repository = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } +rust-version = { workspace = true } + +description = "A comprehensive framework plugin manager for the r2x power systems modeling ecosystem. Simplifies discovery, installation, and management of r2x framework plugins." +readme = "../../README.md" +keywords = ["power-systems", "r2x", "cli", "plugin-manager", "uv"] +categories = ["command-line-utilities", "development-tools"] + +[package.metadata.wix] +upgrade-guid = "AB0643AB-0F2C-4FDC-ACFB-E7886B50614F" +path-guid = "B2C9C3FE-FD0C-4209-910C-A07D1A71C539" +license = false +eula = false + +[dependencies] +atty = "0.2" +clap = { version = "4.5", features = ["derive"] } +colored = "3.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = { version = "0.9", features = ["preserve_order"] } +dirs = "6.0" +serde_yaml = "0.9" +regex = "1.10" +thiserror = "2.0" +once_cell = "1.19" +chrono = { version = "0.4", features = ["serde"] } +indicatif = "0.18" +which = "8.0.0" +walkdir = "2.4" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +anyhow = "1.0" + +[dependencies.r2x-manifest] +path = "../r2x-manifest" + +[dependencies.r2x-ast] +path = "../r2x-ast" + +[dependencies.r2x-python] +path = "../r2x-python" + +[dependencies.r2x-config] +path = "../r2x-config" + +[dependencies.r2x-logger] +path = "../r2x-logger" + +[dev-dependencies] +assert_cmd = "2.1" +predicates = "3.0" +tempfile = "3.8" diff --git a/src/commands/cache.rs b/crates/r2x-cli/src/commands/cache.rs similarity index 92% rename from src/commands/cache.rs rename to crates/r2x-cli/src/commands/cache.rs index 9d1699e..297afa6 100644 --- a/src/commands/cache.rs +++ b/crates/r2x-cli/src/commands/cache.rs @@ -1,6 +1,7 @@ use crate::config_manager::Config; use crate::logger; -use crate::{CacheAction, GlobalOpts}; +use crate::GlobalOpts; +use clap::Subcommand; use std::fs; use std::path::PathBuf; @@ -130,3 +131,13 @@ mod tests { ); } } +#[derive(Subcommand, Debug, Clone)] +pub enum CacheAction { + /// Clean the cache folder + Clean, + /// Get or set cache path + Path { + /// Optional new cache path to set + new_path: Option, + }, +} diff --git a/src/commands/config.rs b/crates/r2x-cli/src/commands/config.rs similarity index 92% rename from src/commands/config.rs rename to crates/r2x-cli/src/commands/config.rs index e73d65d..784b473 100644 --- a/src/commands/config.rs +++ b/crates/r2x-cli/src/commands/config.rs @@ -1,8 +1,25 @@ use crate::config_manager::Config; use crate::logger; -use crate::{ConfigAction, GlobalOpts}; +use crate::GlobalOpts; +use clap::Subcommand; use colored::*; +#[derive(Subcommand, Debug, Clone)] +pub enum ConfigAction { + Show, + Set { + key: String, + value: String, + }, + /// Get or set the path to the config file. + /// If `new_path` is provided, the CLI will set the config path to that value. + /// If omitted, the CLI will print the current configuration file path. + Path { + /// Optional new config path to set + new_path: Option, + }, +} + pub fn handle_config(action: ConfigAction, opts: GlobalOpts) { match action { ConfigAction::Show => match Config::load() { diff --git a/src/commands/init.rs b/crates/r2x-cli/src/commands/init.rs similarity index 87% rename from src/commands/init.rs rename to crates/r2x-cli/src/commands/init.rs index 257f061..bf95658 100644 --- a/src/commands/init.rs +++ b/crates/r2x-cli/src/commands/init.rs @@ -124,11 +124,23 @@ pub fn handle_init(filename: Option, _opts: GlobalOpts) { println!("{} Pipeline file created successfully!", "✔".green()); println!(); println!("Next steps:"); - println!(" 1. Edit {} with your pipeline configuration", target_filename.bold()); + println!( + " 1. Edit {} with your pipeline configuration", + target_filename.bold() + ); println!(" 2. Install plugins: r2x install "); - println!(" 3. List available pipelines: r2x run {} --list", target_filename); - println!(" 4. Run a pipeline: r2x run {} ", target_filename); - println!(" 5. Preview pipeline: r2x run {} --dry-run", target_filename); + println!( + " 3. List available pipelines: r2x run {} --list", + target_filename + ); + println!( + " 4. Run a pipeline: r2x run {} ", + target_filename + ); + println!( + " 5. Preview pipeline: r2x run {} --dry-run", + target_filename + ); } Err(e) => { logger::error(&format!("Failed to create pipeline file: {}", e)); diff --git a/src/commands/mod.rs b/crates/r2x-cli/src/commands/mod.rs similarity index 83% rename from src/commands/mod.rs rename to crates/r2x-cli/src/commands/mod.rs index 242d15d..317a51e 100644 --- a/src/commands/mod.rs +++ b/crates/r2x-cli/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod cache; pub mod config; pub mod init; +pub mod plugins; pub mod python; pub mod read; pub mod run; diff --git a/crates/r2x-cli/src/commands/plugins/clean.rs b/crates/r2x-cli/src/commands/plugins/clean.rs new file mode 100644 index 0000000..9f149b9 --- /dev/null +++ b/crates/r2x-cli/src/commands/plugins/clean.rs @@ -0,0 +1,29 @@ +use crate::logger; +use crate::r2x_manifest::Manifest; +use crate::GlobalOpts; +use colored::Colorize; + +pub fn clean_manifest(yes: bool, _opts: &GlobalOpts) -> Result<(), String> { + let mut manifest = Manifest::load().map_err(|e| format!("Failed to load manifest: {}", e))?; + + if manifest.is_empty() { + logger::warn("Manifest is empty."); + return Ok(()); + } + + let total = manifest.total_plugin_count(); + logger::debug(&format!("Manifest has {} plugin entries.", total)); + + if !yes { + println!("To actually clean, run with --yes flag."); + return Ok(()); + } + + manifest.packages.clear(); + manifest + .save() + .map_err(|e| format!("Failed to save manifest: {}", e))?; + + println!("{}", format!("Removed {} plugin(s)", total).dimmed()); + Ok(()) +} diff --git a/src/plugins/install.rs b/crates/r2x-cli/src/commands/plugins/install.rs similarity index 51% rename from src/plugins/install.rs rename to crates/r2x-cli/src/commands/plugins/install.rs index f3580af..1f29004 100644 --- a/src/plugins/install.rs +++ b/crates/r2x-cli/src/commands/plugins/install.rs @@ -1,13 +1,15 @@ -use crate::config_manager::Config; +use super::setup_config; use crate::logger; -use crate::plugin_manifest::PluginManifest; +use crate::plugins::{ + discovery::{discover_and_register_entry_points_with_deps, DiscoveryOptions}, + install::get_package_info, + package_spec::{build_package_spec, extract_package_name}, +}; +use crate::r2x_manifest::Manifest; use crate::GlobalOpts; use colored::Colorize; use std::process::Command; -use super::discovery::{discover_and_register_entry_points_with_deps, DiscoveryOptions}; -use super::package_spec::{build_package_spec, extract_package_name}; - /// Options for git-based package installation pub struct GitOptions { pub host: Option, @@ -16,6 +18,7 @@ pub struct GitOptions { pub commit: Option, } +/// Install a plugin package pub fn install_plugin( package: &str, editable: bool, @@ -25,12 +28,10 @@ pub fn install_plugin( ) -> Result<(), String> { logger::debug("Loading configuration for plugin installation"); - let (uv_path, venv_path, python_path) = setup_install_config()?; + let (uv_path, venv_path, python_path) = setup_config()?; logger::debug(&format!("Using venv: {}", venv_path)); let total_start = std::time::Instant::now(); - - // Build the package specifier for pip install let package_spec = build_package_spec( package, git_opts.host, @@ -39,23 +40,21 @@ pub fn install_plugin( git_opts.commit, )?; - // Extract package name for version and dependency query let package_name_for_query = extract_package_name(package)?; - // AUDIT CHECK: Is package already installed and registered? - // Skip this check if --no-cache is specified let check_start = std::time::Instant::now(); let is_already_installed = if no_cache { None } else { match get_package_info(&uv_path, &python_path, &package_name_for_query) { Ok((version, _deps)) => { - let manifest = PluginManifest::load().unwrap_or_default(); - let has_plugins_in_manifest = manifest.plugins.iter().any(|(_, plugin)| { - plugin.package_name.as_deref() == Some(package_name_for_query.as_str()) - }); + let manifest = Manifest::load().unwrap_or_default(); + let has_plugins = manifest + .packages + .iter() + .any(|pkg| pkg.name == package_name_for_query && !pkg.plugins.is_empty()); - if has_plugins_in_manifest { + if has_plugins { logger::debug(&format!( "Package '{}' already installed and registered (check took {:?})", package_name_for_query, @@ -70,10 +69,8 @@ pub fn install_plugin( } }; - // If already installed, skip pip install and just audit if is_already_installed.is_some() { - let total_elapsed = total_start.elapsed(); - let elapsed_ms = total_elapsed.as_millis(); + let elapsed_ms = total_start.elapsed().as_millis(); println!( "{}", format!("Audited 1 package in {}ms", elapsed_ms) @@ -87,8 +84,7 @@ pub fn install_plugin( let start = std::time::Instant::now(); match run_pip_install(&uv_path, &python_path, &package_spec, editable, no_cache) { Ok(_) => { - let elapsed = start.elapsed(); - logger::debug(&format!("pip install took: {:?}", elapsed)); + logger::debug(&format!("pip install took: {:?}", start.elapsed())); } Err(e) => { logger::spinner_error(&format!("Failed to install: {}", package)); @@ -96,7 +92,6 @@ pub fn install_plugin( } } - // Get version and dependencies in single pip show call let start = std::time::Instant::now(); let (package_version, dependencies) = match get_package_info(&uv_path, &python_path, &package_name_for_query) { @@ -127,48 +122,47 @@ pub fn install_plugin( logger::spinner_stop(); - let total_elapsed = total_start.elapsed(); - print_summary( + print_install_summary( &package_name_for_query, package_version.as_deref().unwrap_or(""), entry_count, - total_elapsed, + total_start.elapsed(), ); Ok(()) } -fn setup_install_config() -> Result<(String, String, String), String> { - let mut config = Config::load().map_err(|e| { - logger::error(&format!("Failed to load config: {}", e)); - format!("Failed to load config: {}", e) - })?; - - config.ensure_uv_path().map_err(|e| { - logger::error(&format!("Failed to setup uv: {}", e)); - format!("Failed to setup uv: {}", e) - })?; - config.ensure_cache_path().map_err(|e| { - logger::error(&format!("Failed to setup cache: {}", e)); - format!("Failed to setup cache: {}", e) - })?; - config.ensure_venv_path().map_err(|e| { - logger::error(&format!("Failed to setup venv: {}", e)); - format!("Failed to setup venv: {}", e) - })?; - - let uv_path = config - .uv_path - .as_ref() - .cloned() - .ok_or_else(|| "uv path not configured".to_string())?; - let venv_path = config.get_venv_path(); - let python_path = config.get_venv_python_path(); - - Ok((uv_path, venv_path, python_path)) +pub fn show_install_help() -> Result<(), String> { + println!(); + println!("{}", "Install a plugin package".bold()); + println!(); + println!("{}", "Usage:".bold()); + println!(" r2x install [OPTIONS]"); + println!(); + println!("{}", "Arguments:".bold()); + println!(" Package name, local path, or git URL to install"); + println!(); + println!("{}", "Options:".bold()); + println!(" -e, --editable Install in editable mode (for development)"); + println!(" --no-cache Skip metadata cache and force rebuild"); + println!(" --host Git host (default: github.com)"); + println!(" --branch Install from a git branch"); + println!(" --tag Install from a git tag"); + println!(" --commit Install from a git commit hash"); + println!(); + println!("{}", "Examples:".bold()); + println!(" Install from PyPI:\n r2x install r2x-reeds"); + println!("\n Install from local path:\n r2x install ./packages/r2x-reeds"); + println!("\n Install from GitHub (org/repo format):\n r2x install NREL/r2x-reeds"); + println!("\n Install from specific branch:\n r2x install NREL/r2x-reeds --branch develop"); + println!("\n Install from git tag:\n r2x install NREL/r2x-reeds --tag v0.1.0"); + println!( + "\n Install in editable mode for development:\n r2x install -e ./packages/r2x-reeds" + ); + println!(); + Ok(()) } -/// Run pip install via uv fn run_pip_install( uv_path: &str, python_path: &str, @@ -181,15 +175,18 @@ fn run_pip_install( "install".to_string(), "--python".to_string(), python_path.to_string(), + "--prerelease=allow".to_string(), ]; + if no_cache { + install_args.push("--no-cache".to_string()); + } + if editable { install_args.push("-e".to_string()); } - if no_cache { - install_args.push("--no-cache".to_string()); - } + install_args.push(package.to_string()); let debug_flags = if editable && no_cache { "-e --no-cache" @@ -206,8 +203,6 @@ fn run_pip_install( uv_path, debug_flags, python_path, package )); - install_args.push(package.to_string()); - let output = Command::new(uv_path) .args(&install_args) .output() @@ -219,76 +214,31 @@ fn run_pip_install( logger::capture_output(&format!("uv pip install {}", package), &output); if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + logger::error(&format!("pip install failed for package '{}'", package)); - return Err(format!("pip install failed for package '{}'", package)); - } - Ok(()) -} + if !stderr.is_empty() { + logger::error(&format!("STDERR:\n{}", stderr)); + eprintln!("Error details:\n{}", stderr); + } -/// Query package info via a single pip show call. -/// Returns (version, dependencies) tuple. -/// Returns (None, empty_vec) on any error (best-effort, non-fatal). -pub fn get_package_info( - uv_path: &str, - python_path: &str, - package: &str, -) -> Result<(Option, Vec), String> { - let show_output = Command::new(uv_path) - .args(["pip", "show", "--python", python_path, package]) - .output() - .map_err(|e| { - logger::debug(&format!( - "Failed to query package info for '{}': {}", - package, e - )); - format!("Failed to query package info: {}", e) - })?; + if !stdout.is_empty() { + logger::debug(&format!("STDOUT:\n{}", stdout)); + } - if !show_output.status.success() { - logger::debug(&format!( - "pip show failed for package '{}' with status: {}", - package, show_output.status + return Err(format!( + "pip install failed for package '{}': {}", + package, + stderr.lines().next().unwrap_or("unknown error") )); - return Err("pip show failed".to_string()); - } - - let stdout = String::from_utf8_lossy(&show_output.stdout); - let mut version = None; - let mut dependencies = Vec::new(); - - for line in stdout.lines() { - if line.starts_with("Version:") { - version = Some(line.trim_start_matches("Version:").trim().to_string()); - } - if line.starts_with("Requires:") { - let requires_str = line.trim_start_matches("Requires:").trim(); - if !requires_str.is_empty() { - for dep in requires_str.split(',') { - let dep_name = dep.trim(); - if let Some(pkg_name) = dep_name.split(['>', '<', '=', '!', '~']).next() { - let clean_name = pkg_name.trim(); - if !clean_name.is_empty() { - dependencies.push(clean_name.to_string()); - } - } - } - } - } } - logger::debug(&format!( - "Package '{}': version={:?}, {} dependencies", - package, - version, - dependencies.len() - )); - - Ok((version, dependencies)) + Ok(()) } -/// Print installation summary -fn print_summary(pkg: &str, version: &str, count: usize, elapsed: std::time::Duration) { +fn print_install_summary(pkg: &str, version: &str, count: usize, elapsed: std::time::Duration) { let elapsed_ms = elapsed.as_millis(); println!( "{}", @@ -301,62 +251,3 @@ fn print_summary(pkg: &str, version: &str, count: usize, elapsed: std::time::Dur }; println!(" {} {}", "+".bold().green(), disp); } - -/// Show help for the install command -pub fn show_install_help() -> Result<(), String> { - println!(); - println!("{}", "Install a plugin package".bold()); - println!(); - println!("{}", "Usage:".bold()); - println!(" r2x install [OPTIONS]"); - println!(); - println!("{}", "Arguments:".bold()); - println!(" Package name, local path, or git URL to install"); - println!(); - println!("{}", "Options:".bold()); - println!(" -e, --editable Install in editable mode (for development)"); - println!(" --no-cache Skip metadata cache and force rebuild"); - println!(" --host Git host (default: github.com)"); - println!(" --branch Install from a git branch"); - println!(" --tag Install from a git tag"); - println!(" --commit Install from a git commit hash"); - println!(); - println!("{}", "Examples:".bold()); - println!(" Install from PyPI:"); - println!(" r2x install r2x-reeds"); - println!(); - println!(" Install from local path:"); - println!(" r2x install ./packages/r2x-reeds"); - println!(); - println!(" Install from GitHub (org/repo format):"); - println!(" r2x install NREL/r2x-reeds"); - println!(); - println!(" Install from specific branch:"); - println!(" r2x install NREL/r2x-reeds --branch develop"); - println!(); - println!(" Install from git tag:"); - println!(" r2x install NREL/r2x-reeds --tag v0.1.0"); - println!(); - println!(" Install in editable mode for development:"); - println!(" r2x install -e ./packages/r2x-reeds"); - println!(); - Ok(()) -} - -#[cfg(test)] -mod tests { - #[test] - fn test_setup_install_config() { - // Test configuration setup - } - - #[test] - fn test_get_package_info() { - // Test package info extraction - } - - #[test] - fn test_install_plugin_success() { - // Test successful plugin installation - } -} diff --git a/crates/r2x-cli/src/commands/plugins/list.rs b/crates/r2x-cli/src/commands/plugins/list.rs new file mode 100644 index 0000000..0496e67 --- /dev/null +++ b/crates/r2x-cli/src/commands/plugins/list.rs @@ -0,0 +1,93 @@ +use crate::r2x_manifest::Manifest; +use crate::GlobalOpts; +use colored::Colorize; +use std::collections::BTreeMap; + +pub fn list_plugins(_opts: &GlobalOpts) -> Result<(), String> { + let manifest = Manifest::load().map_err(|e| format!("Failed to load manifest: {}", e))?; + + let has_plugins = !manifest.is_empty(); + let has_decorators = manifest + .packages + .iter() + .any(|pkg| !pkg.decorator_registrations.is_empty()); + + if !has_plugins && !has_decorators { + println!("There are no current plugins installed.\n"); + println!( + "To install a plugin, run:\n {} install ", + "r2x".bold().cyan() + ); + return Ok(()); + } + + let mut packages: BTreeMap>> = BTreeMap::new(); + for pkg in &manifest.packages { + for plugin in &pkg.plugins { + packages + .entry(pkg.name.clone()) + .or_default() + .entry(plugin.plugin_type.clone()) + .or_default() + .push(plugin.name.clone()); + } + } + + if has_plugins { + println!("{}", "Plugins:".bold().green()); + for (package_name, types) in &packages { + let total_plugins: usize = types.values().map(|v| v.len()).sum(); + println!( + " {} {}:", + package_name.bold().blue(), + format!("(total plugins: {})", total_plugins).dimmed() + ); + + for (type_name, plugin_names) in types { + println!(" {}:", type_name); + for plugin_name in plugin_names { + println!(" - {}", plugin_name); + } + } + println!(); + } + + println!("{}: {}", "Total plugin packages".bold(), packages.len()); + } + + if has_decorators { + println!(); + println!("{}", "Decorator Registrations:".bold().green()); + + let mut total_decorator_packages = 0; + for pkg in &manifest.packages { + if !pkg.decorator_registrations.is_empty() { + println!( + " {} {}:", + pkg.name.bold().blue(), + format!("({} registrations)", pkg.decorator_registrations.len()).dimmed() + ); + + for reg in &pkg.decorator_registrations { + println!( + " @{}.{}() -> {}", + reg.decorator_class, reg.decorator_method, reg.function_name + ); + if let Some(source) = ®.source_file { + println!(" {}: {}", "Source".dimmed(), source.dimmed()); + } + } + println!(); + total_decorator_packages += 1; + } + } + + println!( + "{}: {}", + "Total packages with decorators".bold(), + total_decorator_packages + ); + } + + Ok(()) +} diff --git a/crates/r2x-cli/src/commands/plugins/mod.rs b/crates/r2x-cli/src/commands/plugins/mod.rs new file mode 100644 index 0000000..bd03927 --- /dev/null +++ b/crates/r2x-cli/src/commands/plugins/mod.rs @@ -0,0 +1,44 @@ +use crate::config_manager::Config; +use crate::logger; + +pub mod clean; +pub mod install; +pub mod list; +pub mod remove; +pub mod sync; + +pub use clean::clean_manifest; +pub use install::{install_plugin, show_install_help, GitOptions}; +pub use list::list_plugins; +pub use remove::remove_plugin; +pub use sync::sync_manifest; + +pub(super) fn setup_config() -> Result<(String, String, String), String> { + let mut config = Config::load().map_err(|e| { + logger::error(&format!("Failed to load config: {}", e)); + format!("Failed to load config: {}", e) + })?; + + config.ensure_uv_path().map_err(|e| { + logger::error(&format!("Failed to setup uv: {}", e)); + format!("Failed to setup uv: {}", e) + })?; + config.ensure_cache_path().map_err(|e| { + logger::error(&format!("Failed to setup cache: {}", e)); + format!("Failed to setup cache: {}", e) + })?; + config.ensure_venv_path().map_err(|e| { + logger::error(&format!("Failed to setup venv: {}", e)); + format!("Failed to setup venv: {}", e) + })?; + + let uv_path = config + .uv_path + .as_ref() + .cloned() + .ok_or_else(|| "uv path not configured".to_string())?; + let venv_path = config.get_venv_path(); + let python_path = config.get_venv_python_path(); + + Ok((uv_path, venv_path, python_path)) +} diff --git a/src/plugins/remove.rs b/crates/r2x-cli/src/commands/plugins/remove.rs similarity index 61% rename from src/plugins/remove.rs rename to crates/r2x-cli/src/commands/plugins/remove.rs index 564474e..3e5cdb2 100644 --- a/src/plugins/remove.rs +++ b/crates/r2x-cli/src/commands/plugins/remove.rs @@ -1,24 +1,24 @@ -use crate::config_manager::Config; +use super::setup_config; use crate::logger; -use crate::plugin_manifest::PluginManifest; +use crate::r2x_manifest::Manifest; use crate::GlobalOpts; -use colored::*; -use std::collections::{HashMap, HashSet}; +use colored::Colorize; use std::process::Command; pub fn remove_plugin(package: &str, _opts: &GlobalOpts) -> Result<(), String> { let mut removed_count = 0usize; let mut orphaned_dependencies = Vec::new(); - match PluginManifest::load() { + match Manifest::load() { Ok(mut manifest) => { orphaned_dependencies = find_orphaned_dependencies(&manifest, package); - removed_count = manifest.remove_plugins_by_package(package); + manifest.remove_decorator_registrations(package); if removed_count > 0 { for dep in &orphaned_dependencies { let count = manifest.remove_plugins_by_package(dep); + manifest.remove_decorator_registrations(dep); if count > 0 { logger::info(&format!("Removing orphaned dependency package '{}'", dep)); removed_count += count; @@ -43,25 +43,9 @@ pub fn remove_plugin(package: &str, _opts: &GlobalOpts) -> Result<(), String> { } } - // Setup config - let mut config = Config::load().map_err(|e| format!("Failed to load config: {}", e))?; - config - .ensure_uv_path() - .map_err(|e| format!("Failed to setup uv: {}", e))?; - config - .ensure_cache_path() - .map_err(|e| format!("Failed to setup cache: {}", e))?; - - let uv_path = config - .uv_path - .as_ref() - .cloned() - .ok_or_else(|| "uv path not configured".to_string())?; - let venv_path = config.get_venv_path(); - + let (uv_path, venv_path, _python_path) = setup_config()?; logger::info(&format!("Using venv: {}", venv_path)); - // Check if package is installed let check_output = Command::new(&uv_path) .args(["pip", "show", "--python", &venv_path, package]) .output() @@ -94,7 +78,6 @@ pub fn remove_plugin(package: &str, _opts: &GlobalOpts) -> Result<(), String> { logger::info(&format!("Package '{}' uninstalled successfully", package)); - // Uninstall orphaned dependencies for orphan_pkg in &orphaned_dependencies { let check_orphan = Command::new(&uv_path) .args(["pip", "show", "--python", &venv_path, orphan_pkg]) @@ -163,66 +146,6 @@ pub fn remove_plugin(package: &str, _opts: &GlobalOpts) -> Result<(), String> { Ok(()) } -/// Find dependencies that would become orphaned if a package is removed. -/// Optimized with single-pass manifest scan and efficient lookups. -fn find_orphaned_dependencies(manifest: &PluginManifest, package: &str) -> Vec { - // Single pass: build efficient lookup maps - let mut explicit_packages: HashSet = HashSet::new(); - let mut dependencies_by_installer: HashMap> = HashMap::new(); - - for plugin in manifest.plugins.values() { - if let Some(pkg_name) = &plugin.package_name { - match plugin.install_type.as_deref() { - Some("explicit") => { - explicit_packages.insert(pkg_name.clone()); - } - Some("dependency") => { - if let Some(installed_by) = &plugin.installed_by { - dependencies_by_installer - .entry(installed_by.clone()) - .or_default() - .push(pkg_name.clone()); - } - } - _ => {} - } - } - } - - // Find orphaned dependencies: those not used by any other explicit package - let mut orphaned = HashSet::new(); - - if let Some(dep_packages) = dependencies_by_installer.get(package) { - for dep_pkg in dep_packages { - // Check if any OTHER installer package needs this dependency - let used_by_other = dependencies_by_installer - .iter() - .any(|(installer, deps)| installer != package && deps.contains(dep_pkg)); - - // If not used by others and not explicitly installed, it's orphaned - if !used_by_other && !explicit_packages.contains(dep_pkg) { - orphaned.insert(dep_pkg.clone()); - } - } - } - - orphaned.into_iter().collect() -} - -#[cfg(test)] -mod tests { - #[test] - fn test_find_orphaned_dependencies() { - // Test finding orphaned dependencies - } - - #[test] - fn test_remove_plugin_not_found() { - // Test removing non-existent plugin - } - - #[test] - fn test_remove_plugin_with_dependencies() { - // Test removing plugin that has dependencies - } +fn find_orphaned_dependencies(_manifest: &Manifest, _package: &str) -> Vec { + Vec::new() } diff --git a/crates/r2x-cli/src/commands/plugins/sync.rs b/crates/r2x-cli/src/commands/plugins/sync.rs new file mode 100644 index 0000000..260adc4 --- /dev/null +++ b/crates/r2x-cli/src/commands/plugins/sync.rs @@ -0,0 +1,86 @@ +use super::setup_config; +use crate::logger; +use crate::plugins::{ + discovery::{discover_and_register_entry_points_with_deps, DiscoveryOptions}, + install::get_package_info, +}; +use crate::r2x_manifest::Manifest; +use crate::GlobalOpts; +use colored::Colorize; + +pub fn sync_manifest(_opts: &GlobalOpts) -> Result<(), String> { + logger::debug("Loading manifest for syncing"); + + let manifest = Manifest::load().map_err(|e| { + logger::error(&format!("Failed to load manifest: {}", e)); + format!("Failed to load manifest: {}", e) + })?; + + if manifest.is_empty() { + logger::warn("No plugins installed. Nothing to sync."); + return Ok(()); + } + + let (uv_path, _venv_path, python_path) = setup_config()?; + let total_start = std::time::Instant::now(); + + let packages_to_sync: Vec = manifest + .packages + .iter() + .map(|pkg| pkg.name.clone()) + .collect(); + + if packages_to_sync.is_empty() { + logger::warn("No packages found in manifest to sync."); + return Ok(()); + } + + let num_packages = packages_to_sync.len(); + logger::step(&format!("Syncing {} package(s)...", num_packages)); + + for package_name in packages_to_sync { + logger::spinner_start(&format!("Syncing: {}", package_name)); + + let (package_version, dependencies) = + match get_package_info(&uv_path, &python_path, &package_name) { + Ok((version, deps)) => (version, deps), + Err(e) => { + logger::spinner_error(&format!( + "Failed to get package info for {}: {}", + package_name, e + )); + logger::debug(&format!("Skipping package: {}", package_name)); + (None, Vec::new()) + } + }; + + match discover_and_register_entry_points_with_deps( + &uv_path, + &python_path, + DiscoveryOptions { + package: package_name.to_string(), + package_name_full: package_name.to_string(), + dependencies, + package_version, + no_cache: false, + }, + ) { + Ok(_) => { + logger::spinner_stop(); + logger::info(&format!("Successfully synced: {}", package_name)); + } + Err(e) => { + logger::spinner_error(&format!("Failed to sync {}: {}", package_name, e)); + return Err(format!("Failed to sync package '{}': {}", package_name, e)); + } + } + } + + let elapsed_ms = total_start.elapsed().as_millis(); + println!( + "{}", + format!("Synced {} package(s) in {}ms", num_packages, elapsed_ms).dimmed() + ); + + Ok(()) +} diff --git a/src/commands/python.rs b/crates/r2x-cli/src/commands/python.rs similarity index 57% rename from src/commands/python.rs rename to crates/r2x-cli/src/commands/python.rs index 690b066..bbbc57b 100644 --- a/src/commands/python.rs +++ b/crates/r2x-cli/src/commands/python.rs @@ -1,12 +1,36 @@ use crate::config_manager::Config; use crate::logger; -use crate::{GlobalOpts, PythonAction, VenvAction}; +use crate::python_bridge::configure_python_venv; +use crate::GlobalOpts; +use clap::Subcommand; use colored::*; use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::process::Command; +#[derive(Subcommand, Debug, Clone)] +pub enum PythonAction { + /// Install a different Python version + Install { + /// Python version to install (e.g., 3.13, 3.12.1) + version: Option, + }, + /// Get the Python executable path in the configured venv + Path, + /// Show the configured Python version and venv information + Show, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum VenvAction { + /// Get or set the venv path + Path { + /// Optional new venv path to set + new_path: Option, + }, +} + /// Handle Python version management pub fn handle_python(action: PythonAction, opts: GlobalOpts) { match action { @@ -33,7 +57,7 @@ pub fn handle_venv(action: Option, skip_confirmation: bool, opts: Gl } } else { // No subcommand: create/recreate venv - handle_venv_create(skip_confirmation, opts); + handle_venv_create(skip_confirmation); } } @@ -42,100 +66,48 @@ fn handle_python_install(version: Option, _opts: GlobalOpts) { logger::debug("Handling Python install command"); match Config::load() { Ok(mut config) => { - if let Err(e) = config.ensure_uv_path() { - logger::error(&format!("Failed to setup uv: {}", e)); - return; - } - - let uv_path = match config.uv_path { - Some(ref path) => path.clone(), - None => { - logger::error("uv path not found"); - return; - } - }; - - let version_str = version.unwrap_or_else(|| { - config - .python_version - .clone() - .unwrap_or_else(|| "3.12".to_string()) - }); + let version_str = version + .or_else(|| config.python_version.clone()) + .unwrap_or_else(|| "3.12".to_string()); config.python_version = Some(version_str.clone()); + if let Err(e) = config.save() { + logger::error(&format!("Failed to save config: {}", e)); + return; + } - let venv_path = PathBuf::from(config.get_venv_path()); - + let venv_path = config.get_venv_path(); logger::step(&format!( "Installing Python {} and creating venv...", version_str )); - logger::debug(&format!("Using uv path: {}", uv_path)); - logger::debug(&format!("Venv path: {}", venv_path.display())); - - let venv_output = Command::new(&uv_path) - .args([ - "venv", - "--clear", - "--python", - &version_str, - venv_path.to_str().unwrap(), - ]) - .output(); - - match venv_output { - Ok(output) if output.status.success() => { - logger::capture_output( - &format!("uv venv --python {}", version_str), - &output, - ); - config.venv_path = Some(venv_path.to_str().unwrap().to_string()); - - if let Err(e) = config.save() { - logger::error(&format!("Failed to save config: {}", e)); - return; - } + if let Err(e) = remove_existing_venv(&venv_path) { + logger::error(&e); + return; + } + match configure_python_venv() { + Ok(python_path) => { logger::info(&format!( "Configuration saved with Python version {}", version_str )); - - let python_check = Command::new(&uv_path) - .args(["run", "--python", &version_str, "python", "--version"]) - .output(); - - match python_check { - Ok(output) if output.status.success() => { - logger::capture_output( - &format!( - "uv run --python {} python --version", - version_str - ), - &output, - ); - logger::success(&format!( - "Python {} installed and venv created successfully", - version_str - )); - } - _ => { - logger::warn("Failed to verify Python installation"); - } + if let Some(actual_version) = verify_python_version(&python_path) { + logger::success(&format!( + "Python {} installed (reported {}). Venv ready at {}", + version_str, + actual_version, + PathBuf::from(&venv_path).display() + )); + } else { + logger::success(&format!( + "Python {} installed and venv created at {}", + version_str, venv_path + )); } } - Ok(output) => { - logger::capture_output( - &format!("uv venv --python {}", version_str), - &output, - ); - logger::error(&format!( - "Failed to create virtual environment for Python {}", - version_str - )); - } Err(e) => { - logger::error(&format!("Failed to execute uv command: {}", e)); + logger::error(&format!("Failed to configure Python environment: {}", e)); } } } @@ -150,15 +122,7 @@ fn handle_python_path(_opts: GlobalOpts) { logger::debug("Handling python path command"); match Config::load() { Ok(config) => { - let venv_path = config.get_venv_path(); - - #[cfg(unix)] - let python_path = format!("{}/bin/python", venv_path); - - #[cfg(windows)] - let python_path = format!("{}\\Scripts\\python.exe", venv_path); - - println!("{}", python_path); + println!("{}", config.get_venv_python_path()); } Err(e) => { logger::error(&format!("Failed to load config: {}", e)); @@ -166,19 +130,13 @@ fn handle_python_path(_opts: GlobalOpts) { } } -fn handle_venv_create(skip_confirmation: bool, opts: GlobalOpts) { +fn handle_venv_create(skip_confirmation: bool) { logger::debug(&format!( "Handling venv create command (skip_confirmation: {})", skip_confirmation )); match Config::load() { - Ok(mut config) => { - // Ensure uv is installed first - if let Err(e) = config.ensure_uv_path() { - logger::error(&format!("Failed to setup uv: {}", e)); - return; - } - + Ok(config) => { let venv_path = config.get_venv_path(); let venv_dir = PathBuf::from(&venv_path); @@ -212,14 +170,22 @@ fn handle_venv_create(skip_confirmation: bool, opts: GlobalOpts) { logger::debug("Skipping confirmation (--yes flag or R2X_VENV_YES set)"); } - if let Err(e) = fs::remove_dir_all(&venv_dir) { - logger::error(&format!("Failed to remove existing venv: {}", e)); + if let Err(e) = remove_existing_venv(&venv_path) { + logger::error(&e); return; } - logger::debug(&format!("Removed existing venv at {}", venv_path)); } - create_venv(&config, opts); + match configure_python_venv() { + Ok(python_path) => { + logger::success(&format!( + "Virtual environment ready at {} (python {})", + venv_path, + python_path.display() + )); + } + Err(e) => logger::error(&format!("Failed to configure venv: {}", e)), + } if !skip_confirmation && std::env::var("R2X_VENV_YES").is_err() { println!( @@ -236,43 +202,6 @@ fn handle_venv_create(skip_confirmation: bool, opts: GlobalOpts) { } } -// Create virtual environment using UV and specified python version -fn create_venv(config: &Config, _opts: GlobalOpts) { - let venv_path = config.get_venv_path(); - let version = config.python_version.as_deref().unwrap_or("3.12"); - - if let Some(ref uv_path) = config.uv_path { - logger::debug(&format!("Creating virtual environment at: {}", venv_path)); - logger::debug(&format!( - "Running {} venv {} --python {}", - uv_path, venv_path, version - )); - - let output = Command::new(uv_path) - .args(["venv", &venv_path, "--python", version]) - .output(); - - match output { - Ok(out) if out.status.success() => { - logger::capture_output("uv venv", &out); - logger::success(&format!( - "Virtual environment created successfully at {}", - venv_path - )); - } - Ok(out) => { - logger::capture_output("uv venv", &out); - logger::error("Failed to create virtual environment"); - } - Err(e) => { - logger::error(&format!("Failed to execute uv command: {}", e)); - } - } - } else { - logger::error("uv path not configured"); - } -} - fn handle_venv_path(new_path: Option, _opts: GlobalOpts) { logger::debug("Handling venv path command"); match Config::load() { @@ -344,6 +273,34 @@ fn is_valid_venv(path: &Path) -> bool { bin_dir.exists() && bin_dir.is_dir() } +fn remove_existing_venv(venv_path: &str) -> Result<(), String> { + let venv_dir = PathBuf::from(venv_path); + if venv_dir.exists() { + logger::debug(&format!("Removing existing venv at {}", venv_path)); + fs::remove_dir_all(&venv_dir) + .map_err(|e| format!("Failed to remove existing venv: {}", e))?; + } + Ok(()) +} + +fn verify_python_version(python_path: &Path) -> Option { + if !python_path.exists() { + return None; + } + + match Command::new(python_path).args(["--version"]).output() { + Ok(output) if output.status.success() => { + let raw = if output.stdout.is_empty() { + output.stderr + } else { + output.stdout + }; + Some(String::from_utf8_lossy(&raw).trim().to_string()) + } + _ => None, + } +} + fn handle_python_show(_opts: GlobalOpts) { logger::debug("Handling python show command"); match Config::load() { @@ -351,41 +308,26 @@ fn handle_python_show(_opts: GlobalOpts) { let version = config.python_version.as_deref().unwrap_or("not configured"); let venv_path = config.get_venv_path(); - let venv_exists = PathBuf::from(&venv_path).exists(); + let python_path = PathBuf::from(config.get_venv_python_path()); + let venv_exists = python_path.exists(); - // Try to get the actual Python version from the venv if it exists let mut actual_version_str = String::new(); let mut version_mismatch = false; if venv_exists { - let python_path = if cfg!(windows) { - PathBuf::from(&venv_path).join("Scripts").join("python.exe") - } else { - PathBuf::from(&venv_path).join("bin").join("python") - }; - - if python_path.exists() { - match Command::new(&python_path).args(["--version"]).output() { - Ok(output) if output.status.success() => { - let actual_version = - String::from_utf8_lossy(&output.stdout).trim().to_string(); - actual_version_str = actual_version.clone(); - - // Check for version mismatch - // Extract version number from "Python X.Y.Z" format - if let Some(version_num) = actual_version.split_whitespace().nth(1) { - let configured_short = - version.split('.').take(2).collect::>().join("."); - let actual_short = - version_num.split('.').take(2).collect::>().join("."); - if configured_short != actual_short && version != "not configured" { - version_mismatch = true; - } - } - } - _ => { - logger::debug("Could not determine actual Python version"); + if let Some(actual_version) = verify_python_version(&python_path) { + actual_version_str = actual_version.clone(); + + if let Some(version_num) = actual_version.split_whitespace().nth(1) { + let configured_short = + version.split('.').take(2).collect::>().join("."); + let actual_short = + version_num.split('.').take(2).collect::>().join("."); + if configured_short != actual_short && version != "not configured" { + version_mismatch = true; } } + } else { + logger::debug("Could not determine actual Python version"); } } @@ -423,14 +365,6 @@ mod tests { } } - fn verbose_opts() -> GlobalOpts { - GlobalOpts { - quiet: false, - verbose: 1, - log_python: false, - } - } - #[test] fn test_python_install() { handle_python( diff --git a/crates/r2x-cli/src/commands/read.rs b/crates/r2x-cli/src/commands/read.rs new file mode 100644 index 0000000..0e288ce --- /dev/null +++ b/crates/r2x-cli/src/commands/read.rs @@ -0,0 +1,365 @@ +use crate::config_manager::Config; +use crate::logger; +use crate::GlobalOpts; +use atty::Stream; +use clap::Parser; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Parser, Debug)] +pub struct ReadCommand { + /// Path to JSON file to read. If not provided, reads from stdin + pub file: Option, +} + +pub fn handle_read(cmd: ReadCommand, _opts: GlobalOpts) -> Result<(), Box> { + logger::debug("Starting read command"); + + // Load configuration + let mut config = Config::load()?; + let venv_path = config.ensure_venv_path()?; + logger::debug(&format!("Using virtual environment at {}", venv_path)); + + // Get Python executable path (ensured via ensure_venv_path) + let python_exe = config.get_venv_python_path(); + if !Path::new(&python_exe).exists() { + return Err(format!( + "Python executable not found at {}. Recreate the venv via `r2x python venv create`.", + python_exe + ) + .into()); + } + + logger::debug(&format!("Python executable: {}", python_exe)); + + ensure_prerequisites(&mut config, &python_exe)?; + + // Load JSON input + let json_file_path = match cmd.file { + Some(file_path) => { + logger::debug(&format!("Reading JSON from file: {}", file_path.display())); + file_path + } + None => { + if atty::is(Stream::Stdin) { + logger::info( + "No JSON input detected; please provide --file or pipe JSON via stdin.", + ); + return Err( + "No JSON input provided; either use --file or pipe data into `r2x read`".into(), + ); + } + + logger::debug("Reading JSON from stdin"); + let mut json_data = String::new(); + std::io::stdin() + .read_to_string(&mut json_data) + .map_err(|e| format!("Failed to read from stdin: {}", e))?; + + let cache_dir = config.ensure_cache_path()?; + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let temp_json = PathBuf::from(cache_dir).join(format!("stdin_input_{}.json", unique)); + fs::write(&temp_json, &json_data) + .map_err(|e| format!("Failed to write temporary JSON file: {}", e))?; + + logger::debug(&format!( + "Saved stdin to temporary file: {}", + temp_json.display() + )); + temp_json + } + }; + + // Generate Python initialization code + let file_path_str = json_file_path + .to_str() + .ok_or("Invalid file path")? + .replace('\\', "\\\\"); + + let python_code = format!( + r#" +import json +import os +import sys as py_sys +import traceback +from IPython.terminal.embed import InteractiveShellEmbed +from traitlets.config import Config +from r2x_core.system import System + +JSON_PATH = r'''{}''' + +try: + with open(JSON_PATH, 'r') as handle: + data = json.load(handle) + cwd = os.getcwd() + system = System.from_dict(data, cwd) +except Exception: + traceback.print_exc() + py_sys.exit(1) + +cfg = Config() +cfg.TerminalInteractiveShell.confirm_exit = False +cfg.TerminalInteractiveShell.display_banner = False +cfg.TerminalInteractiveShell.banner1 = "" +cfg.TerminalInteractiveShell.banner2 = "" +cfg.TerminalInteractiveShell.colors = "Linux" + +force_simple_env = os.environ.get("R2X_FORCE_SIMPLE_PROMPT") +if force_simple_env is None: + simple_prompt = not (py_sys.stdin.isatty() and py_sys.stdout.isatty()) +else: + simple_prompt = force_simple_env.lower() in ("1", "true", "yes", "on") + +cfg.TerminalInteractiveShell.simple_prompt = simple_prompt + +if os.environ.get("R2X_READ_NONINTERACTIVE") == "1": + print("System available as `sys`. Run sys.info() for details.") + py_sys.exit(0) + +context = {{"sys": system}} +InteractiveShellEmbed(config=cfg, banner1="", exit_msg="")( + header="System available as `sys` (use sys.info())", + local_ns=context, + global_ns=context, +) +"#, + file_path_str + ); + + logger::debug("Generated Python initialization code"); + + logger::debug("Launching interactive IPython session..."); + + let ipython_dir = ensure_ipython_dir(); + let stdin_is_tty = atty::is(Stream::Stdin); + let stdout_is_tty = atty::is(Stream::Stdout); + let interactive_prompt = stdin_is_tty && stdout_is_tty; + let (_tty_attached, stdin_tty, stdout_tty, stderr_tty) = acquire_tty_stdio(); + + // Spawn IPython bootstrap script with interactive embed + let mut command = Command::new(&python_exe); + command + .arg("-c") + .arg(&python_code) + .env("PYTHONUNBUFFERED", "1"); + + command + .stdin(stdin_tty) + .stdout(stdout_tty) + .stderr(stderr_tty); + + if interactive_prompt { + command + .env("PY_COLORS", "1") + .env("CLICOLOR_FORCE", "1") + .env("R2X_FORCE_SIMPLE_PROMPT", "0"); + } else { + command.env("R2X_FORCE_SIMPLE_PROMPT", "1"); + } + + if std::env::var_os("TERM").is_none() { + command.env("TERM", "xterm-256color"); + } + + if let Some(dir) = &ipython_dir { + command.env("IPYTHONDIR", dir); + } + + let mut child = command + .spawn() + .map_err(|e| format!("Failed to spawn IPython process: {}", e))?; + + logger::debug("IPython process spawned, waiting for completion"); + + // Wait for IPython to finish + let status = child + .wait() + .map_err(|e| format!("Failed to wait for IPython process: {}", e))?; + + if !status.success() { + let exit_code = status.code().unwrap_or(-1); + logger::debug(&format!("IPython exited with code: {}", exit_code)); + return Err(format!("IPython exited with code {}", exit_code).into()); + } + + logger::debug("IPython session completed successfully"); + Ok(()) +} + +fn ensure_prerequisites( + config: &mut Config, + python_exe: &str, +) -> Result<(), Box> { + ensure_module_installed(config, python_exe, "IPython", "IPython", "IPython")?; + let r2x_core_spec = config.get_r2x_core_package_spec(); + ensure_module_installed( + config, + python_exe, + "r2x_core.system", + &r2x_core_spec, + "r2x-core", + )?; + Ok(()) +} + +#[cfg(unix)] +fn acquire_tty_stdio() -> (bool, Stdio, Stdio, Stdio) { + let (stdin_attached, stdin) = match std::fs::File::open("/dev/tty") { + Ok(handle) => (true, Stdio::from(handle)), + Err(_) => (false, Stdio::inherit()), + }; + + let (stdout_attached, stdout) = match std::fs::OpenOptions::new().write(true).open("/dev/tty") { + Ok(handle) => (true, Stdio::from(handle)), + Err(_) => (false, Stdio::inherit()), + }; + + let (stderr_attached, stderr) = match std::fs::OpenOptions::new().write(true).open("/dev/tty") { + Ok(handle) => (true, Stdio::from(handle)), + Err(_) => (false, Stdio::inherit()), + }; + + ( + stdin_attached || stdout_attached || stderr_attached, + stdin, + stdout, + stderr, + ) +} + +#[cfg(windows)] +fn acquire_tty_stdio() -> (bool, Stdio, Stdio, Stdio) { + let (stdin_attached, stdin) = match std::fs::OpenOptions::new().read(true).open("CONIN$") { + Ok(handle) => (true, Stdio::from(handle)), + Err(_) => (false, Stdio::inherit()), + }; + + let (stdout_attached, stdout) = match std::fs::OpenOptions::new().write(true).open("CONOUT$") { + Ok(handle) => (true, Stdio::from(handle)), + Err(_) => (false, Stdio::inherit()), + }; + + let (stderr_attached, stderr) = match std::fs::OpenOptions::new().write(true).open("CONOUT$") { + Ok(handle) => (true, Stdio::from(handle)), + Err(_) => (false, Stdio::inherit()), + }; + + ( + stdin_attached || stdout_attached || stderr_attached, + stdin, + stdout, + stderr, + ) +} + +fn ensure_module_installed( + config: &mut Config, + python_exe: &str, + module_name: &str, + package_spec: &str, + display_name: &str, +) -> Result<(), Box> { + if module_exists(python_exe, module_name) { + logger::debug(&format!("{} already available in venv", display_name)); + return Ok(()); + } + + logger::info(&format!( + "{} not found in venv; installing via uv pip install", + display_name + )); + + install_package_with_spinner(config, python_exe, package_spec, display_name)?; + Ok(()) +} + +fn module_exists(python_exe: &str, module_name: &str) -> bool { + Command::new(python_exe) + .arg("-c") + .arg(&format!("import {}", module_name)) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +fn install_package_with_spinner( + config: &mut Config, + python_exe: &str, + package_spec: &str, + display_name: &str, +) -> Result<(), Box> { + let uv_path = config.ensure_uv_path()?; + let mut install_cmd = Command::new(&uv_path); + install_cmd + .arg("pip") + .arg("install") + .arg("--python") + .arg(python_exe) + .arg(package_spec); + + logger::debug(&format!("Running: {:?}", install_cmd)); + logger::spinner_start(&format!("Installing {} into venv...", display_name)); + + let output = install_cmd.output().map_err(|e| { + logger::spinner_error(&format!("Failed to install {} into venv", display_name)); + format!("Failed to run uv pip install: {}", e) + })?; + + logger::capture_output(&format!("uv pip install {}", package_spec), &output); + + if !output.status.success() { + logger::spinner_error(&format!("Failed to install {} into venv", display_name)); + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("uv pip install {} failed: {}", package_spec, stderr).into()); + } + + logger::spinner_success(&format!("Installed {} into venv", display_name)); + Ok(()) +} + +fn ensure_ipython_dir() -> Option { + let config_path = Config::path(); + if let Some(dir) = config_path.parent() { + let ipython_dir = dir.join("ipython"); + if let Err(err) = fs::create_dir_all(&ipython_dir) { + logger::debug(&format!( + "Failed to create IPython dir {}: {}", + ipython_dir.display(), + err + )); + None + } else { + Some(ipython_dir) + } + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_read_command_creation() { + let cmd = ReadCommand { file: None }; + assert!(cmd.file.is_none()); + } + + #[test] + fn test_read_command_with_file() { + let cmd = ReadCommand { + file: Some(PathBuf::from("test.json")), + }; + assert!(cmd.file.is_some()); + } +} diff --git a/crates/r2x-cli/src/commands/run/mod.rs b/crates/r2x-cli/src/commands/run/mod.rs new file mode 100644 index 0000000..bcc7e8d --- /dev/null +++ b/crates/r2x-cli/src/commands/run/mod.rs @@ -0,0 +1,153 @@ +use crate::errors::{BridgeError, ManifestError, PipelineError}; +use crate::logger; +use crate::r2x_manifest; +use crate::GlobalOpts; +use clap::Parser; +use pipeline::handle_pipeline_mode; +use plugin::handle_plugin_command; +use r2x_manifest::runtime::{build_runtime_bindings, RuntimeBindings}; +use r2x_python::plugin_invoker::PluginInvocationTimings; +use std::time::Duration; + +mod pipeline; +mod plugin; + +#[derive(Debug)] +pub enum RunError { + Manifest(ManifestError), + Bridge(BridgeError), + Pipeline(PipelineError), + Config(String), + PluginNotFound(String), + InvalidArgs(String), + Verification(String), +} + +impl std::fmt::Display for RunError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RunError::Manifest(e) => write!(f, "Manifest error: {}", e), + RunError::Bridge(e) => write!(f, "Python bridge error: {}", e), + RunError::Pipeline(e) => write!(f, "Pipeline error: {}", e), + RunError::Config(msg) => write!(f, "Configuration error: {}", msg), + RunError::PluginNotFound(name) => { + write!(f, "Plugin '{}' not found in manifest", name) + } + RunError::InvalidArgs(msg) => write!(f, "Invalid arguments: {}", msg), + RunError::Verification(msg) => { + write!(f, "Package verification error: {}", msg) + } + } + } +} + +impl std::error::Error for RunError {} + +impl From for RunError { + fn from(e: ManifestError) -> Self { + RunError::Manifest(e) + } +} + +impl From for RunError { + fn from(e: BridgeError) -> Self { + RunError::Bridge(e) + } +} + +impl From for RunError { + fn from(e: PipelineError) -> Self { + RunError::Pipeline(e) + } +} + +#[derive(Parser, Debug)] +pub struct RunCommand { + #[command(subcommand)] + pub command: Option, + #[arg(value_name = "YAML_PATH")] + pub yaml_path: Option, + #[arg(value_name = "NAME")] + pub pipeline_name: Option, + #[arg(long)] + pub list: bool, + #[arg(long)] + pub print: bool, + #[arg(long)] + pub dry_run: bool, + #[arg(short = 'o', long, value_name = "FILE")] + pub output: Option, +} + +#[derive(Parser, Debug)] +pub enum RunSubcommand { + Plugin(PluginCommand), +} + +#[derive(Parser, Debug)] +pub struct PluginCommand { + pub plugin_name: Option, + #[arg(long)] + pub show_help: bool, + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub args: Vec, +} + +pub fn handle_run(cmd: RunCommand, _opts: GlobalOpts) -> Result<(), RunError> { + match cmd.command { + Some(RunSubcommand::Plugin(plugin_cmd)) => handle_plugin_command(plugin_cmd), + None => { + let yaml_path = cmd.yaml_path.unwrap_or_else(|| "pipeline.yaml".to_string()); + handle_pipeline_mode( + yaml_path, + cmd.pipeline_name, + cmd.list, + cmd.print, + cmd.dry_run, + cmd.output, + ) + } + } +} + +pub(super) fn runtime_bindings_from_disc( + disc_plugin: &r2x_manifest::DiscoveryPlugin, +) -> Result { + build_runtime_bindings(disc_plugin).map_err(RunError::Config) +} + +pub(super) fn build_call_target(bindings: &RuntimeBindings) -> Result { + let obj = &bindings.callable; + + let target = if obj.callable_type == "class" { + if let Some(call_method) = &bindings.call_method { + format!("{}:{}.{}", obj.module, obj.name, call_method) + } else { + format!("{}:{}", obj.module, obj.name) + } + } else { + format!("{}:{}", obj.module, obj.name) + }; + + Ok(target) +} + +pub(super) fn format_duration(duration: Duration) -> String { + let total_ms = duration.as_millis(); + if total_ms < 1000 { + format!("{}ms", total_ms) + } else { + format!("{:.2}s", duration.as_secs_f64()) + } +} + +pub(super) fn print_plugin_timing_breakdown(timings: &PluginInvocationTimings) { + logger::debug(&format!( + "Plugin python invocation {}", + format_duration(timings.python_invocation) + )); + logger::debug(&format!( + "Plugin serialization {}", + format_duration(timings.serialization) + )); +} diff --git a/crates/r2x-cli/src/commands/run/pipeline.rs b/crates/r2x-cli/src/commands/run/pipeline.rs new file mode 100644 index 0000000..b02a34b --- /dev/null +++ b/crates/r2x-cli/src/commands/run/pipeline.rs @@ -0,0 +1,536 @@ +use super::{runtime_bindings_from_disc, RunError}; +use crate::errors::PipelineError; +use crate::logger; +use crate::package_verification; +use crate::pipeline_config::PipelineConfig; +use crate::python_bridge::Bridge; +use crate::r2x_manifest::{self, Manifest}; +use colored::Colorize; +use r2x_config::Config; +use std::collections::HashSet; +use std::fs; +use std::path::PathBuf; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +pub(super) fn handle_pipeline_mode( + yaml_path: String, + pipeline_name: Option, + list: bool, + print: bool, + dry_run: bool, + output: Option, +) -> Result<(), RunError> { + let config = PipelineConfig::load(&yaml_path)?; + + if list { + list_pipelines(&config); + } else if print { + if let Some(name) = pipeline_name { + print_pipeline_config(&config, &name)?; + } else { + return Err(RunError::InvalidArgs( + "Pipeline name required with --print".to_string(), + )); + } + } else if let Some(name) = pipeline_name { + if dry_run { + show_pipeline_flow(&config, &name)?; + } else { + run_pipeline(&config, &name, output.as_deref())?; + } + } else { + return Err(RunError::InvalidArgs( + "Pipeline name required for execution".to_string(), + )); + } + + Ok(()) +} + +fn list_pipelines(config: &PipelineConfig) { + let pipelines = config.list_pipelines(); + + if pipelines.is_empty() { + logger::warn("No pipelines found in YAML file"); + return; + } + + logger::step("Available Pipelines:"); + for name in pipelines { + if let Some(steps) = config.get_pipeline(&name) { + println!(" {} ({} steps)", name, steps.len()); + for step in steps { + println!(" - {}", step); + } + } + } +} + +fn print_pipeline_config(config: &PipelineConfig, pipeline_name: &str) -> Result<(), RunError> { + let output = config.print_pipeline_config(pipeline_name)?; + println!("{}", output); + Ok(()) +} + +fn show_pipeline_flow(config: &PipelineConfig, pipeline_name: &str) -> Result<(), RunError> { + let pipeline = config + .get_pipeline(pipeline_name) + .ok_or_else(|| PipelineError::PipelineNotFound(pipeline_name.to_string()))?; + + let manifest = Manifest::load()?; + + logger::success(&format!("Pipeline: {}", pipeline_name)); + println!("\nPipeline flow (--dry-run):"); + + for (index, plugin_name) in pipeline.iter().enumerate() { + let (_pkg, disc_plugin) = manifest + .packages + .iter() + .find_map(|pkg| { + pkg.plugins + .iter() + .find(|p| p.name == *plugin_name) + .map(|p| (pkg, p)) + }) + .ok_or_else(|| RunError::PluginNotFound(plugin_name.to_string()))?; + + let bindings = runtime_bindings_from_disc(disc_plugin)?; + let has_obj = bindings.callable.callable_type == "class"; + let input_marker = if index > 0 { "← stdin" } else { "" }; + let output_marker = if has_obj { "→ stdout" } else { "" }; + + print!(" {}", plugin_name); + if !input_marker.is_empty() { + print!(" {}", input_marker.dimmed()); + } + if !output_marker.is_empty() { + print!(" {}", output_marker.dimmed()); + } + println!(); + } + + println!( + "\n{} No actual execution. Use without --dry-run to run the pipeline.", + "✔".green() + ); + + Ok(()) +} + +fn run_pipeline( + config: &PipelineConfig, + pipeline_name: &str, + output_file: Option<&str>, +) -> Result<(), RunError> { + let pipeline = config + .get_pipeline(pipeline_name) + .ok_or_else(|| PipelineError::PipelineNotFound(pipeline_name.to_string()))?; + + let manifest = Manifest::load()?; + let total_steps = pipeline.len(); + + logger::debug("Verifying packages for pipeline..."); + for plugin_name in pipeline.iter() { + package_verification::verify_and_ensure_plugin(&manifest, plugin_name) + .map_err(|e| RunError::Verification(e.to_string()))?; + } + logger::debug("All pipeline packages verified"); + + let pipeline_start = Instant::now(); + eprintln!("{}", format!("Running: {}", pipeline_name).cyan().bold()); + + let mut current_stdin: Option = None; + + let resolved_output_folder = if let Some(folder) = &config.output_folder { + Some( + config + .substitute_string(folder) + .map_err(RunError::Pipeline)?, + ) + } else { + None + }; + + let mut current_store_path: Option = None; + + for (idx, plugin_name) in pipeline.iter().enumerate() { + let step_num = idx + 1; + logger::spinner_start(&format!(" {} [{}/{}]", plugin_name, step_num, total_steps)); + let step_start = Instant::now(); + + let (pkg, disc_plugin) = manifest + .packages + .iter() + .find_map(|pkg| { + pkg.plugins + .iter() + .find(|p| p.name == *plugin_name) + .map(|p| (pkg, p)) + }) + .ok_or_else(|| RunError::PluginNotFound(plugin_name.to_string()))?; + + let bindings = runtime_bindings_from_disc(disc_plugin)?; + + let yaml_config = if config.config.contains_key(plugin_name) { + config.get_plugin_config_json(plugin_name)? + } else { + "{}".to_string() + }; + + if let Ok(serde_json::Value::Object(map)) = + serde_json::from_str::(&yaml_config) + { + if let Some(store_path) = map.get("store_path").and_then(|value| value.as_str()) { + current_store_path = Some(store_path.to_string()); + } + } + + let normalized_io: Option = + bindings.io_type.as_deref().map(normalize_io_type_value); + let pipeline_input = current_stdin.as_deref(); + let uses_stdin = matches!(normalized_io.as_deref(), Some("stdin") | Some("both")); + let stdin_json = if uses_stdin { pipeline_input } else { None }; + + let pipeline_overrides = + prepare_pipeline_overrides(pipeline_input, &bindings, plugin_name)?; + + let final_config_json = build_plugin_config( + &bindings, + &pkg.name, + &yaml_config, + resolved_output_folder.as_deref(), + current_store_path.as_deref(), + pipeline_overrides.as_deref(), + )?; + + let target = super::build_call_target(&bindings)?; + let bridge = Bridge::get()?; + logger::debug(&format!("Invoking: {}", target)); + logger::debug(&format!("Config: {}", final_config_json)); + + let invocation_result = match bridge.invoke_plugin( + &target, + &final_config_json, + stdin_json, + Some(disc_plugin), + ) { + Ok(inv_result) => { + let elapsed = step_start.elapsed(); + logger::spinner_success(&format!( + "{} [{}/{}] ({})", + plugin_name, + step_num, + total_steps, + super::format_duration(elapsed) + )); + if logger::get_verbosity() > 0 { + if let Some(timings) = &inv_result.timings { + super::print_plugin_timing_breakdown(timings); + } + } + inv_result + } + Err(e) => { + let elapsed = step_start.elapsed(); + logger::spinner_error(&format!( + "{} [{}/{}] ({})", + plugin_name, + step_num, + total_steps, + super::format_duration(elapsed) + )); + return Err(RunError::Bridge(e)); + } + }; + + let result = invocation_result.output; + + let produces_stdout = matches!(normalized_io.as_deref(), Some("stdout") | Some("both")); + if produces_stdout && !result.is_empty() && result != "null" { + logger::debug(&format!("Plugin produced output ({} bytes)", result.len())); + current_stdin = Some(result); + } else { + logger::debug("Plugin produced no output or output not used"); + } + } + + eprintln!( + "{}", + format!( + "Finished in: {}", + super::format_duration(pipeline_start.elapsed()) + ) + .green() + .bold() + ); + + if let Some(final_output) = current_stdin { + if let Some(output_path) = output_file { + logger::step(&format!("Writing output to: {}", output_path)); + std::fs::write(output_path, final_output.as_bytes()) + .map_err(|e| RunError::Pipeline(PipelineError::Io(e)))?; + logger::success(&format!("Output saved to: {}", output_path)); + } else { + println!("{}", final_output); + } + } + + Ok(()) +} + +fn normalize_io_type_value(raw: &str) -> String { + let trimmed = raw.trim(); + let without_prefix = trimmed + .strip_prefix("IOType.") + .or_else(|| trimmed.strip_prefix("IOType::")) + .unwrap_or(trimmed); + without_prefix.to_ascii_lowercase() +} + +fn prepare_pipeline_overrides( + pipeline_input: Option<&str>, + bindings: &r2x_manifest::runtime::RuntimeBindings, + plugin_name: &str, +) -> Result, RunError> { + let Some(raw) = pipeline_input else { + return Ok(None); + }; + + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + let Some(target_field) = determine_json_path_field(bindings, plugin_name) else { + return Ok(Some(raw.to_string())); + }; + + let parsed = match serde_json::from_str::(raw) { + Ok(value) => value, + Err(_) => return Ok(Some(raw.to_string())), + }; + + match parsed { + serde_json::Value::Object(map) => { + if map.contains_key(target_field) || !looks_like_system_payload(&map) { + Ok(Some(raw.to_string())) + } else { + let persisted = persist_pipeline_system_json(raw)?; + logger::debug(&format!( + "Persisted upstream stdout for '{}' to {}", + plugin_name, persisted + )); + let mut override_map = serde_json::Map::new(); + override_map.insert( + target_field.to_string(), + serde_json::Value::String(persisted), + ); + Ok(Some(serde_json::Value::Object(override_map).to_string())) + } + } + _ => Ok(Some(raw.to_string())), + } +} + +fn determine_json_path_field( + bindings: &r2x_manifest::runtime::RuntimeBindings, + plugin_name: &str, +) -> Option<&'static str> { + if let Some(config) = &bindings.config { + if config.parameters.contains_key("json_path") { + return Some("json_path"); + } + if config.parameters.contains_key("path") { + return Some("path"); + } + } + + if bindings.callable.parameters.contains_key("json_path") { + return Some("json_path"); + } + if bindings.callable.parameters.contains_key("path") { + return Some("path"); + } + + if plugin_name.contains("parser") { + return Some("json_path"); + } + + None +} + +fn looks_like_system_payload(map: &serde_json::Map) -> bool { + if map.contains_key("components") || map.contains_key("system") { + return true; + } + if let Some(data_obj) = map.get("data").and_then(|v| v.as_object()) { + return data_obj.contains_key("components") + || data_obj.contains_key("system_information") + || data_obj.contains_key("system"); + } + false +} + +fn persist_pipeline_system_json(payload: &str) -> Result { + let mut config = Config::load().map_err(|e| RunError::Config(e.to_string()))?; + let cache_root = config + .ensure_cache_path() + .map_err(|e| RunError::Config(e.to_string()))?; + let dir = PathBuf::from(cache_root).join("pipeline-systems"); + fs::create_dir_all(&dir) + .map_err(PipelineError::Io) + .map_err(RunError::Pipeline)?; + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| RunError::Config(format!("System clock error: {}", e)))? + .as_millis(); + let filename = format!( + "system_{}_{}_{}.json", + timestamp, + std::process::id(), + rand_suffix() + ); + let path = dir.join(filename); + fs::write(&path, payload) + .map_err(PipelineError::Io) + .map_err(RunError::Pipeline)?; + Ok(path.to_string_lossy().to_string()) +} + +fn rand_suffix() -> u32 { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + COUNTER.fetch_add(1, Ordering::Relaxed) +} + +fn build_plugin_config( + bindings: &r2x_manifest::runtime::RuntimeBindings, + package_name: &str, + yaml_config_json: &str, + output_folder: Option<&str>, + inherited_store_path: Option<&str>, + stdin_overrides: Option<&str>, +) -> Result { + let mut yaml_config: serde_json::Value = serde_json::from_str(yaml_config_json) + .map_err(|e| RunError::Config(format!("Failed to parse YAML config: {}", e)))?; + + if let Some(overrides) = stdin_overrides { + if let Ok(value) = serde_json::from_str::(overrides) { + merge_config_values(&mut yaml_config, value); + } + } + + let mut final_config = serde_json::Map::new(); + let obj = &bindings.callable; + if obj.callable_type == "class" { + let mut config_class_params = serde_json::Map::new(); + let mut constructor_params = serde_json::Map::new(); + let config_param_names: HashSet = bindings + .config + .as_ref() + .map(|config_meta| config_meta.parameters.keys().cloned().collect()) + .unwrap_or_default(); + + if let serde_json::Value::Object(ref yaml_map) = yaml_config { + for (key, value) in yaml_map { + if key == "store" { + continue; + } else if config_param_names.contains(key) { + config_class_params.insert(key.clone(), value.clone()); + } else if obj.parameters.contains_key(key) { + constructor_params.insert(key.clone(), value.clone()); + } else { + config_class_params.insert(key.clone(), value.clone()); + } + } + } + + if !config_class_params.is_empty() && obj.parameters.contains_key("config") { + final_config.insert( + "config".to_string(), + serde_json::Value::Object(config_class_params), + ); + } + + final_config.extend(constructor_params); + + if obj.parameters.contains_key("path") + && !final_config.contains_key("path") + && matches!(yaml_config, serde_json::Value::Object(_)) + { + if let serde_json::Value::Object(ref yaml_map) = yaml_config { + if let Some(path_value) = yaml_map + .get("path") + .or_else(|| yaml_map.get("store_path")) + .cloned() + { + final_config.insert("path".to_string(), path_value); + } + } + } + + let needs_store = + bindings.requires_store.unwrap_or(false) || obj.parameters.contains_key("data_store"); + + if needs_store { + let store_value = if let serde_json::Value::Object(ref yaml_map) = yaml_config { + match yaml_map.get("store") { + Some(value) => value.clone(), + None => { + if let Some(explicit_path) = yaml_map.get("store_path").cloned() { + explicit_path + } else if let Some(inherited) = inherited_store_path { + serde_json::Value::String(inherited.to_string()) + } else { + fallback_store_value(package_name, output_folder)? + } + } + } + } else { + if let Some(inherited) = inherited_store_path { + serde_json::Value::String(inherited.to_string()) + } else { + fallback_store_value(package_name, output_folder)? + } + }; + + final_config.insert("data_store".to_string(), store_value); + } + } else if let serde_json::Value::Object(ref yaml_map) = yaml_config { + final_config.extend(yaml_map.clone()); + } + + serde_json::to_string(&serde_json::Value::Object(final_config)) + .map_err(|e| RunError::Config(format!("Failed to serialize final config: {}", e))) +} + +fn merge_config_values(target: &mut serde_json::Value, overrides: serde_json::Value) { + match (target, overrides) { + (serde_json::Value::Object(target_map), serde_json::Value::Object(override_map)) => { + for (key, value) in override_map { + match target_map.get_mut(&key) { + Some(existing) => merge_config_values(existing, value), + None => { + target_map.insert(key, value); + } + } + } + } + (target_value, override_value) => { + *target_value = override_value; + } + } +} + +fn fallback_store_value( + _package_name: &str, + output_folder: Option<&str>, +) -> Result { + let output_folder = output_folder.unwrap_or("/tmp/r2x-output"); + let store_path = format!("{}/store", output_folder); + std::fs::create_dir_all(&store_path) + .map_err(|e| RunError::Config(format!("Failed to create store directory: {}", e)))?; + + Ok(serde_json::Value::String(store_path)) +} diff --git a/crates/r2x-cli/src/commands/run/plugin.rs b/crates/r2x-cli/src/commands/run/plugin.rs new file mode 100644 index 0000000..8596c32 --- /dev/null +++ b/crates/r2x-cli/src/commands/run/plugin.rs @@ -0,0 +1,173 @@ +use super::{PluginCommand, RunError}; +use crate::help::show_plugin_help; +use crate::logger; +use crate::package_verification; +use crate::python_bridge::Bridge; +use crate::r2x_manifest::Manifest; +use colored::Colorize; +use r2x_python::plugin_invoker::PluginInvocationResult; +use std::collections::BTreeMap; +use std::time::Instant; + +pub(super) fn handle_plugin_command(cmd: PluginCommand) -> Result<(), RunError> { + match cmd.plugin_name { + Some(plugin_name) => { + if cmd.show_help { + show_plugin_help(&plugin_name) + .map_err(|e| RunError::Config(format!("Help error: {}", e)))?; + } else { + run_plugin(&plugin_name, &cmd.args)?; + } + } + None => { + list_available_plugins()?; + } + } + Ok(()) +} + +fn list_available_plugins() -> Result<(), RunError> { + let manifest = Manifest::load()?; + + if manifest.is_empty() { + println!("No plugins installed.\n"); + println!("To install a plugin, run:\n r2x install "); + return Ok(()); + } + + println!("Available plugins:\n"); + let mut packages: BTreeMap>> = BTreeMap::new(); + + for pkg in &manifest.packages { + for plugin in &pkg.plugins { + packages + .entry(pkg.name.clone()) + .or_default() + .entry(plugin.plugin_type.clone()) + .or_default() + .push(plugin.name.clone()); + } + } + + let mut first = true; + for (package_name, types) in &packages { + if !first { + println!(); + } + first = false; + + println!("{}:", package_name.bold()); + for (type_name, plugin_names) in types { + println!(" {}:", type_name); + for plugin_name in plugin_names { + println!(" - {}", plugin_name); + } + } + } + + println!("Run a plugin with:\n r2x run plugin [args...]\n"); + println!("Show plugin help:\n r2x run plugin --show-help"); + + Ok(()) +} + +fn run_plugin(plugin_name: &str, args: &[String]) -> Result<(), RunError> { + logger::step(&format!("Running plugin: {}", plugin_name)); + logger::debug(&format!("Received args: {:?}", args)); + + let manifest = Manifest::load()?; + let (_pkg, disc_plugin) = manifest + .packages + .iter() + .find_map(|pkg| { + pkg.plugins + .iter() + .find(|p| p.name == plugin_name) + .map(|p| (pkg, p)) + }) + .ok_or_else(|| RunError::PluginNotFound(plugin_name.to_string()))?; + + let bindings = super::runtime_bindings_from_disc(disc_plugin)?; + + package_verification::verify_and_ensure_plugin(&manifest, plugin_name) + .map_err(|e| RunError::Verification(e.to_string()))?; + + let config_map = parse_plugin_args(args)?; + let config_json = serde_json::to_string(&config_map) + .map_err(|e| RunError::Config(format!("Failed to serialize config: {}", e)))?; + + let target = super::build_call_target(&bindings)?; + + let bridge = Bridge::get()?; + logger::debug(&format!("Invoking plugin with target: {}", target)); + logger::debug(&format!("Config: {}", config_json)); + + let start = Instant::now(); + let invocation_result = bridge.invoke_plugin(&target, &config_json, None, Some(disc_plugin))?; + let PluginInvocationResult { + output: result, + timings, + } = invocation_result; + let elapsed = start.elapsed(); + let duration_msg = format!("({})", super::format_duration(elapsed).dimmed()); + + if !result.is_empty() && result != "null" { + println!("{}", result); + } + + if logger::get_verbosity() > 0 { + logger::success(&format!( + "{} execution completed {}", + plugin_name, duration_msg + )); + + if let Some(timings) = timings { + super::print_plugin_timing_breakdown(&timings); + } + } + + Ok(()) +} + +fn parse_plugin_args(args: &[String]) -> Result { + let mut config = serde_json::json!({}); + + for arg in args { + if let Some(eq_pos) = arg.find('=') { + let key = &arg[..eq_pos]; + let value_str = &arg[eq_pos + 1..]; + let python_key = key.replace('-', "_"); + let value = parse_json_value(value_str)?; + config[python_key] = value; + } else { + return Err(RunError::InvalidArgs(format!( + "Invalid argument format: '{}'. Expected key=value", + arg + ))); + } + } + + Ok(config) +} + +fn parse_json_value(value_str: &str) -> Result { + if let Ok(json_val) = serde_json::from_str::(value_str) { + return Ok(json_val); + } + + match value_str.to_lowercase().as_str() { + "true" => return Ok(serde_json::json!(true)), + "false" => return Ok(serde_json::json!(false)), + _ => {} + } + + if let Ok(num) = value_str.parse::() { + return Ok(serde_json::json!(num)); + } + + if let Ok(num) = value_str.parse::() { + return Ok(serde_json::json!(num)); + } + + Ok(serde_json::json!(value_str)) +} diff --git a/crates/r2x-cli/src/common.rs b/crates/r2x-cli/src/common.rs new file mode 100644 index 0000000..273caf1 --- /dev/null +++ b/crates/r2x-cli/src/common.rs @@ -0,0 +1,34 @@ +//! Common types and utilities shared across modules + +use clap::Parser; + +/// Global CLI options available to all commands +#[derive(Parser, Debug, Clone)] +pub struct GlobalOpts { + #[arg(short, long, global = true, help = "Decrease verbosity")] + pub quiet: bool, + + #[arg(short, long, global = true, action = clap::ArgAction::Count, help = "Increase verbosity (-v for debug, -vv for trace)")] + pub verbose: u8, + + #[arg( + long, + global = true, + help = "Show Python logs on console (always logged to file)" + )] + pub log_python: bool, +} + +impl GlobalOpts { + /// Get the effective verbosity level + /// - 0: quiet/warn only + /// - 1: debug (-v) + /// - 2: trace (-vv) + pub fn verbosity_level(&self) -> u8 { + if self.quiet { + 0 + } else { + self.verbose + } + } +} diff --git a/crates/r2x-cli/src/errors.rs b/crates/r2x-cli/src/errors.rs new file mode 100644 index 0000000..d0fcc1f --- /dev/null +++ b/crates/r2x-cli/src/errors.rs @@ -0,0 +1,43 @@ +//! Centralized error types for the r2x project +//! +//! This module defines all error types used across the project, +//! providing a unified error handling interface. + +use std::io; +use thiserror::Error; + +pub use r2x_manifest::errors::ManifestError; +pub use r2x_python::errors::BridgeError; + +/// Errors that can occur during pipeline configuration operations +#[derive(Error, Debug)] +pub enum PipelineError { + #[error("IO error: {0}")] + Io(#[from] io::Error), + + #[error("Failed to parse pipeline YAML: {0}")] + Parse(#[from] serde_yaml::Error), + + #[error("Variable '{0}' not found in variables section")] + VariableNotFound(String), + + #[error("Pipeline '{0}' not found in YAML")] + PipelineNotFound(String), + + #[error("Invalid configuration: {0}")] + InvalidConfig(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pipeline_error_display() { + let err = PipelineError::PipelineNotFound("test-pipeline".to_string()); + assert_eq!( + err.to_string(), + "Pipeline 'test-pipeline' not found in YAML" + ); + } +} diff --git a/src/help.rs b/crates/r2x-cli/src/help.rs similarity index 53% rename from src/help.rs rename to crates/r2x-cli/src/help.rs index b974273..a09ba0d 100644 --- a/src/help.rs +++ b/crates/r2x-cli/src/help.rs @@ -1,10 +1,10 @@ use crate::logger; -use crate::plugin_manifest::PluginManifest; +use crate::r2x_manifest::{runtime::build_runtime_bindings, Manifest}; use colored::Colorize; /// Show help for the run command when invoked with no arguments pub fn show_run_help() -> Result<(), String> { - let manifest = PluginManifest::load().map_err(|e| format!("Failed to load manifest: {}", e))?; + let manifest = Manifest::load().map_err(|e| format!("Failed to load manifest: {}", e))?; println!(); println!("{}", "No pipeline or plugin specified.".bold()); @@ -13,16 +13,16 @@ pub fn show_run_help() -> Result<(), String> { // Show installed plugins if !manifest.is_empty() { println!("{}", "Installed plugins:".bold()); - let plugins = manifest.list_plugins(); - for (name, plugin) in &plugins { - let plugin_type = plugin.plugin_type.as_deref().unwrap_or("unknown"); - let desc = plugin.description.as_deref().unwrap_or("No description"); - println!( - " {} {} - {}", - name.cyan(), - format!("({})", plugin_type).dimmed(), - desc - ); + for pkg in &manifest.packages { + for plugin in &pkg.plugins { + let plugin_type = &plugin.plugin_type; + println!( + " {} {} - from package {}", + plugin.name.cyan(), + format!("({})", plugin_type).dimmed(), + pkg.name.dimmed() + ); + } } println!(); } else { @@ -37,10 +37,10 @@ pub fn show_run_help() -> Result<(), String> { println!(" r2x run [pipeline-name]"); println!(); println!(" Run a plugin directly:"); - println!(" r2x run --plugin [OPTIONS]"); + println!(" r2x run plugin [OPTIONS]"); println!(); println!(" Get plugin help:"); - println!(" r2x run --plugin --show-help"); + println!(" r2x run plugin --show-help"); println!(); println!(" List pipelines in YAML:"); println!(" r2x run --list"); @@ -54,33 +54,32 @@ pub fn show_run_help() -> Result<(), String> { /// Show detailed help for a specific plugin pub fn show_plugin_help(plugin_name: &str) -> Result<(), String> { - let manifest = PluginManifest::load().map_err(|e| format!("Failed to load manifest: {}", e))?; - - let plugin = manifest - .plugins - .get(plugin_name) + let manifest = Manifest::load().map_err(|e| format!("Failed to load manifest: {}", e))?; + + let (_pkg, disc_plugin) = manifest + .packages + .iter() + .find_map(|pkg| { + pkg.plugins + .iter() + .find(|p| p.name == plugin_name) + .map(|p| (pkg, p)) + }) .ok_or_else(|| format!("Plugin '{}' not found in manifest", plugin_name))?; - logger::step(&format!("Plugin: {}", plugin_name)); + let bindings = build_runtime_bindings(disc_plugin) + .map_err(|e| format!("Failed to load plugin '{}': {}", plugin_name, e))?; - if let Some(desc) = &plugin.description { - println!("\n{}", desc); - } - - if let Some(doc) = &plugin.doc { - println!("\n{}", doc); - } + logger::step(&format!("Plugin: {}", plugin_name)); - if let Some(plugin_type) = &plugin.plugin_type { - println!("\nType: {}", plugin_type); - } + println!("\nType: {}", disc_plugin.plugin_type); - if let Some(io_type) = &plugin.io_type { + if let Some(io_type) = &bindings.io_type { println!("I/O: {}", io_type); } // Check if plugin requires data store - let needs_store = check_needs_datastore(plugin); + let needs_store = check_needs_datastore(&bindings); if needs_store { println!("\nRequires data store: yes"); @@ -90,36 +89,35 @@ pub fn show_plugin_help(plugin_name: &str) -> Result<(), String> { } // Show callable parameters - if let Some(obj) = &plugin.obj { - println!("\nCallable: {}.{}", obj.module, obj.name); - if let Some(call_method) = &plugin.call_method { - println!("Method: {}", call_method); - } + let obj = &bindings.callable; + println!("\nCallable: {}.{}", obj.module, obj.name); + if let Some(call_method) = &bindings.call_method { + println!("Method: {}", call_method); + } - if !obj.parameters.is_empty() { - println!("\nCallable Parameters:"); - for (name, param) in &obj.parameters { - let annotation = param.annotation.as_deref().unwrap_or("Any"); - let required = if param.is_required { - "required" - } else { - "optional" - }; - let default = param - .default - .as_deref() - .map(|d| format!(" (default: {})", d)) - .unwrap_or_default(); - println!( - " --{:<20} {:<15} {}{}", - name, annotation, required, default - ); - } + if !obj.parameters.is_empty() { + println!("\nCallable Parameters:"); + for (name, param) in &obj.parameters { + let annotation = param.annotation.as_deref().unwrap_or("Any"); + let required = if param.is_required { + "required" + } else { + "optional" + }; + let default = param + .default + .as_deref() + .map(|d| format!(" (default: {})", d)) + .unwrap_or_default(); + println!( + " --{:<20} {:<15} {}{}", + name, annotation, required, default + ); } } // Show config parameters - if let Some(config) = &plugin.config { + if let Some(config) = &bindings.config { println!("\nConfiguration Class: {}.{}", config.module, config.name); if !config.parameters.is_empty() { println!("\nConfiguration Parameters:"); @@ -161,33 +159,12 @@ pub fn show_plugin_help(plugin_name: &str) -> Result<(), String> { } /// Check if a plugin requires a DataStore -fn check_needs_datastore(plugin: &crate::plugin_manifest::Plugin) -> bool { - let mut needs_store = plugin.requires_store.unwrap_or(false); - - if let Some(obj) = &plugin.obj { - for param in obj.parameters.values() { - if let Some(annotation) = ¶m.annotation { - if annotation.contains("DataStore") || annotation.contains("data_store") { - needs_store = true; - break; - } - } - } - } - if !needs_store { - if let Some(config) = &plugin.config { - for param in config.parameters.values() { - if let Some(annotation) = ¶m.annotation { - if annotation.contains("DataStore") || annotation.contains("data_store") { - needs_store = true; - break; - } - } - } - } +fn check_needs_datastore(bindings: &r2x_manifest::runtime::RuntimeBindings) -> bool { + if bindings.callable.parameters.contains_key("data_store") { + return true; } - needs_store + bindings.requires_store.unwrap_or(false) } #[cfg(test)] diff --git a/crates/r2x-cli/src/lib.rs b/crates/r2x-cli/src/lib.rs new file mode 100644 index 0000000..d3ad7c1 --- /dev/null +++ b/crates/r2x-cli/src/lib.rs @@ -0,0 +1,31 @@ +//! R2X library - expose modules for testing +//! +//! This library exposes core modules needed for testing and integration. + +pub mod commands; +pub mod common; +pub mod errors; +pub mod help; +pub mod package_verification; +pub mod pipeline_config; +pub mod plugin_manifest; +pub mod plugins; + +// Re-export dedicated crates so internal modules can continue using the previous paths. +pub use r2x_ast; +pub use r2x_config as config_manager; +pub use r2x_logger as logger; +pub use r2x_manifest; +pub use r2x_python as python_bridge; + +// Re-export common types for convenience +pub use common::GlobalOpts; +pub use errors::PipelineError; +pub use python_bridge::errors::BridgeError; +pub use r2x_manifest::errors::ManifestError; + +// Re-export manifest types from new module for convenience +pub use r2x_manifest::{ + ConstructorArg, DecoratorRegistration, DiscoveryPlugin, FunctionParameter, FunctionSignature, + Manifest, Metadata, Package, VarArgType, +}; diff --git a/src/main.rs b/crates/r2x-cli/src/main.rs similarity index 71% rename from src/main.rs rename to crates/r2x-cli/src/main.rs index 6d0580b..503fadc 100644 --- a/src/main.rs +++ b/crates/r2x-cli/src/main.rs @@ -1,24 +1,18 @@ use clap::{Parser, Subcommand}; - -mod commands; -pub mod config_manager; -pub mod errors; -pub mod help; -pub mod logger; -pub mod package_verification; -pub mod pipeline_config; -pub mod plugin_cache; -pub mod plugin_manifest; -mod plugins; -pub mod python_bridge; -use commands::{cache, config, init, python, read, run}; -use plugins::{ - clean_manifest, install_plugin, list_plugins, remove_plugin, show_install_help, GitOptions, +use r2x::{ + commands::{ + cache::{self, CacheAction}, + config::{self, ConfigAction}, + init, plugins, + python::{self, PythonAction, VenvAction}, + read, run, + }, + config_manager, logger, GlobalOpts, }; #[derive(Parser)] #[command(name = "r2x")] -#[command(version = "0.1.0")] +#[command(version = env!("CARGO_PKG_VERSION"))] #[command( about = "Energy translator framework", long_about = "R2X is a CLI tool for translating models." @@ -31,33 +25,6 @@ struct Cli { command: Commands, } -#[derive(Parser, Debug, Clone)] -pub struct GlobalOpts { - #[arg(short, long, global = true, help = "Decrease verbosity")] - pub quiet: bool, - - #[arg(short, long, global = true, action = clap::ArgAction::Count, help = "Increase verbosity (-v for debug, -vv for trace)")] - pub verbose: u8, - - #[arg( - long, - global = true, - help = "Show Python logs on console (always logged to file)" - )] - pub log_python: bool, -} - -impl GlobalOpts { - pub fn verbosity_level(&self) -> u8 { - if self.quiet { - 0 - } else { - // 0 = quiet/warn only, 1 = debug (-v), 2 = trace (-vv) - self.verbose - } - } -} - #[derive(Subcommand)] enum Commands { /// Cache configuration @@ -98,6 +65,9 @@ enum Commands { }, /// Remove a plugin Remove { plugin: String }, + /// Sync plugin manifest (re-run plugin discovery for all installed packages) + /// Useful when developing plugins locally with -e to refresh the plugin registry + Sync, /// Clean the plugin manifest (removes all installed plugins) Clean { /// Skip confirmation prompt @@ -133,33 +103,6 @@ enum Commands { }, } -#[derive(Subcommand)] -enum CacheAction { - /// Clean the cache folder - Clean, - /// Get or set cache path - Path { - /// Optional new cache path to set - new_path: Option, - }, -} - -#[derive(Subcommand)] -enum ConfigAction { - Show, - Set { - key: String, - value: String, - }, - /// Get or set the path to the config file. - /// If `new_path` is provided, the CLI will set the config path to that value. - /// If omitted, the CLI will print the current configuration file path. - Path { - /// Optional new config path to set - new_path: Option, - }, -} - #[derive(Subcommand)] enum PluginsAction { /// List current plugins. @@ -196,28 +139,6 @@ enum PluginsAction { }, } -#[derive(Subcommand)] -enum PythonAction { - /// Install a different Python version - Install { - /// Python version to install (e.g., 3.13, 3.12.1) - version: Option, - }, - /// Get the Python executable path in the configured venv - Path, - /// Show the configured Python version and venv information - Show, -} - -#[derive(Subcommand)] -pub enum VenvAction { - /// Get or set the venv path - Path { - /// Optional new venv path to set - new_path: Option, - }, -} - fn main() { let cli = Cli::parse(); @@ -243,7 +164,7 @@ fn main() { config::handle_config(action, cli.global); } Commands::List => { - if let Err(e) = list_plugins(&cli.global) { + if let Err(e) = plugins::list_plugins(&cli.global) { logger::error(&e); } } @@ -257,11 +178,11 @@ fn main() { commit, } => match plugin { Some(pkg) => { - if let Err(e) = install_plugin( + if let Err(e) = plugins::install_plugin( &pkg, editable, no_cache, - GitOptions { + plugins::GitOptions { host, branch, tag, @@ -273,18 +194,23 @@ fn main() { } } None => { - if let Err(e) = show_install_help() { + if let Err(e) = plugins::show_install_help() { logger::error(&e); } } }, Commands::Remove { plugin } => { - if let Err(e) = remove_plugin(&plugin, &cli.global) { + if let Err(e) = plugins::remove_plugin(&plugin, &cli.global) { + logger::error(&e); + } + } + Commands::Sync => { + if let Err(e) = plugins::sync_manifest(&cli.global) { logger::error(&e); } } Commands::Clean { yes } => { - if let Err(e) = clean_manifest(yes, &cli.global) { + if let Err(e) = plugins::clean_manifest(yes, &cli.global) { logger::error(&e); } } diff --git a/src/package_verification.rs b/crates/r2x-cli/src/package_verification.rs similarity index 91% rename from src/package_verification.rs rename to crates/r2x-cli/src/package_verification.rs index 827be22..cbbe71f 100644 --- a/src/package_verification.rs +++ b/crates/r2x-cli/src/package_verification.rs @@ -2,7 +2,7 @@ use crate::config_manager::Config; use crate::logger; -use crate::plugin_manifest::PluginManifest; +use crate::r2x_manifest::Manifest; use std::collections::HashSet; use std::path::PathBuf; use std::process::Command; @@ -54,32 +54,27 @@ impl std::error::Error for VerificationError {} /// * `Ok(VerificationResult::Missing(packages))` - List of missing/changed packages /// * `Err(VerificationError)` - Critical error during verification pub fn verify_plugin_packages( - manifest: &PluginManifest, + manifest: &Manifest, plugin_key: &str, ) -> Result { logger::debug(&format!("Verifying packages for plugin: {}", plugin_key)); - // Get plugin from manifest - let plugin = match manifest.get_plugin(plugin_key) { - Some(p) => p, - None => { - return Err(VerificationError::VerificationFailed(format!( + // Find the plugin and its package from manifest + let package_name = manifest + .packages + .iter() + .find_map(|pkg| { + pkg.plugins + .iter() + .find(|p| p.name == plugin_key) + .map(|_| pkg.name.clone()) + }) + .ok_or_else(|| { + VerificationError::VerificationFailed(format!( "Plugin '{}' not found in manifest", plugin_key - ))) - } - }; - - // Get package name from plugin metadata - let package_name = match &plugin.package_name { - Some(name) => name, - None => { - return Err(VerificationError::VerificationFailed(format!( - "Plugin '{}' has no package_name in manifest", - plugin_key - ))) - } - }; + )) + })?; // Get venv path let config = Config::load().map_err(|e| { @@ -92,7 +87,7 @@ pub fn verify_plugin_packages( } // Check if package is installed - let missing_packages = check_packages_installed(&venv_path, &[package_name.as_str()])?; + let missing_packages = check_packages_installed(&venv_path, &[&package_name])?; if missing_packages.is_empty() { logger::debug(&format!("Package '{}' verified successfully", package_name)); @@ -210,14 +205,15 @@ pub fn ensure_packages(packages: Vec, config: &Config) -> Result<(), Ver .as_ref() .ok_or_else(|| VerificationError::ReinstallFailed("uv not configured".to_string()))?; - let venv_path = config.get_venv_path(); + let python_exe = config.get_venv_python_path(); // Build uv pip install command let mut cmd = Command::new(uv_path); cmd.arg("pip") .arg("install") .arg("--python") - .arg(&venv_path); + .arg(&python_exe) + .arg("--prerelease=allow"); // Add all packages for package in &packages { @@ -266,7 +262,7 @@ pub fn ensure_packages(packages: Vec, config: &Config) -> Result<(), Ver /// use r2x::package_verification::verify_and_ensure_plugin; /// use r2x::plugin_manifest::PluginManifest; /// -/// let manifest = PluginManifest::load()?; +/// let manifest = Manifest::load()?; /// /// // This will verify r2x-reeds is installed /// // If missing, it will automatically reinstall it @@ -275,7 +271,7 @@ pub fn ensure_packages(packages: Vec, config: &Config) -> Result<(), Ver /// // Now safe to run the plugin /// ``` pub fn verify_and_ensure_plugin( - manifest: &PluginManifest, + manifest: &Manifest, plugin_key: &str, ) -> Result<(), VerificationError> { logger::debug(&format!("Verifying and ensuring plugin: {}", plugin_key)); @@ -307,9 +303,7 @@ pub fn verify_and_ensure_plugin( /// /// # Returns /// Set of package names that need to be installed -pub fn verify_all_packages( - manifest: &PluginManifest, -) -> Result, VerificationError> { +pub fn verify_all_packages(manifest: &Manifest) -> Result, VerificationError> { let mut missing_packages = HashSet::new(); // Get venv path @@ -324,10 +318,8 @@ pub fn verify_all_packages( // Collect all unique package names from manifest let mut all_packages: HashSet<&str> = HashSet::new(); - for (_key, plugin) in manifest.list_plugins() { - if let Some(package_name) = &plugin.package_name { - all_packages.insert(package_name.as_str()); - } + for pkg in &manifest.packages { + all_packages.insert(&pkg.name); } // Check which packages are missing diff --git a/src/pipeline_config.rs b/crates/r2x-cli/src/pipeline_config.rs similarity index 88% rename from src/pipeline_config.rs rename to crates/r2x-cli/src/pipeline_config.rs index 3946303..12c3b20 100644 --- a/src/pipeline_config.rs +++ b/crates/r2x-cli/src/pipeline_config.rs @@ -2,7 +2,7 @@ use crate::errors::PipelineError; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; /// Pipeline configuration from YAML #[derive(Serialize, Deserialize, Debug, Clone)] @@ -27,7 +27,18 @@ pub struct PipelineConfig { impl PipelineConfig { /// Load pipeline configuration from YAML file pub fn load>(path: P) -> Result { - let content = fs::read_to_string(path)?; + let path_ref = path.as_ref(); + let content = match fs::read_to_string(path_ref) { + Ok(content) => content, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + if let Some(fallback) = Self::resolve_fallback_path(path_ref) { + fs::read_to_string(fallback)? + } else { + return Err(PipelineError::Io(err)); + } + } + Err(err) => return Err(PipelineError::Io(err)), + }; let config: PipelineConfig = serde_yaml::from_str(&content)?; Ok(config) } @@ -189,11 +200,24 @@ impl PipelineConfig { Ok(output) } + + fn resolve_fallback_path(original: &Path) -> Option { + let mut candidates = Vec::new(); + + if original.extension().is_none() { + candidates.push(original.with_extension("yaml")); + candidates.push(original.with_extension("yml")); + } + + candidates.into_iter().find(|candidate| candidate.exists()) + } } #[cfg(test)] mod tests { use super::*; + use std::fs; + use tempfile::TempDir; #[test] fn test_variable_substitution_dollar_brace() { @@ -297,4 +321,21 @@ mod tests { panic!("Expected mapping"); } } + + #[test] + fn test_load_with_fallback_extension() { + let dir = TempDir::new().unwrap(); + let yaml_path = dir.path().join("sample-pipeline.yaml"); + fs::write( + &yaml_path, + r#" +pipelines: + demo: ["step"] +"#, + ) + .unwrap(); + + let config = PipelineConfig::load(dir.path().join("sample-pipeline")).unwrap(); + assert!(config.get_pipeline("demo").is_some()); + } } diff --git a/crates/r2x-cli/src/plugin_manifest.rs b/crates/r2x-cli/src/plugin_manifest.rs new file mode 100644 index 0000000..0836663 --- /dev/null +++ b/crates/r2x-cli/src/plugin_manifest.rs @@ -0,0 +1,5 @@ +//! DEPRECATED: This module is for backward compatibility only. +//! All types and functionality have been moved to the r2x_manifest module. +//! This file re-exports the old API pointing to the new implementation. + +pub use crate::r2x_manifest::*; diff --git a/crates/r2x-cli/src/plugins/config.rs b/crates/r2x-cli/src/plugins/config.rs new file mode 100644 index 0000000..06ffeef --- /dev/null +++ b/crates/r2x-cli/src/plugins/config.rs @@ -0,0 +1,3 @@ +//! Configuration for plugin discovery +//! +//! This module is reserved for future plugin configuration needs. diff --git a/crates/r2x-cli/src/plugins/discovery.rs b/crates/r2x-cli/src/plugins/discovery.rs new file mode 100644 index 0000000..6dc14a9 --- /dev/null +++ b/crates/r2x-cli/src/plugins/discovery.rs @@ -0,0 +1,190 @@ +//! Plugin discovery orchestration +//! +//! Manages the discovery and registration of plugins from packages, +//! handling caching, dependencies, and manifest updates. + +use crate::logger; +use crate::plugins::{find_package_path, utils, AstDiscovery}; +use crate::r2x_manifest::Manifest; + +/// Options for plugin discovery and registration +pub struct DiscoveryOptions { + pub package: String, + pub package_name_full: String, + pub dependencies: Vec, + pub package_version: Option, + pub no_cache: bool, +} + +/// Discover and register plugins from a package and its dependencies +pub fn discover_and_register_entry_points_with_deps( + _uv_path: &str, + _python_path: &str, + opts: DiscoveryOptions, +) -> Result { + let package = &opts.package; + let package_name_full = &opts.package_name_full; + let dependencies = &opts.dependencies; + let no_cache = opts.no_cache; + let package_version = opts.package_version.as_deref().unwrap_or("unknown"); + + // Get venv path from config for entry_points.txt lookup + let venv_path = crate::config_manager::Config::load() + .ok() + .map(|c| c.get_venv_path()); + + // Load manifest + let mut manifest = match Manifest::load() { + Ok(m) => m, + Err(e) => { + logger::warn(&format!("Failed to load manifest: {}", e)); + Manifest { + metadata: crate::r2x_manifest::Metadata { + version: "1.0".to_string(), + generated_at: chrono::Utc::now().to_rfc3339(), + uv_lock_path: None, + }, + packages: Vec::new(), + } + } + }; + + // Check if we already have this package in the manifest + let has_package_cached = manifest + .packages + .iter() + .any(|p| p.name == *package_name_full); + + let (discovered_plugins, decorator_regs) = if has_package_cached && !no_cache { + if let Some(pkg) = manifest + .packages + .iter() + .find(|p| p.name == *package_name_full) + { + (pkg.plugins.clone(), pkg.decorator_registrations.clone()) + } else { + (Vec::new(), Vec::new()) + } + } else { + let package_path = find_package_path(package_name_full) + .map_err(|e| format!("Failed to locate package '{}': {}", package_name_full, e))?; + + AstDiscovery::discover_plugins( + &package_path, + package_name_full, + venv_path.as_deref(), + Some(package_version), + ) + .map_err(|e| format!("Failed to discover plugins for '{}': {}", package, e))? + }; + + for plugin in &discovered_plugins { + logger::debug(&format!( + "Discovered plugin '{}' with {} resolved reference(s)", + plugin.name, + plugin.resolved_references.len() + )); + } + + let mut total_plugins = discovered_plugins.len(); + + if total_plugins == 0 { + logger::warn(&format!("No plugins found in package '{}'", package)); + return Ok(0); + } + + logger::info(&format!( + "Found {} plugin(s) in package '{}'", + total_plugins, package + )); + + { + let pkg = manifest.get_or_create_package(package_name_full); + pkg.entry_points_dist_info = String::new(); + pkg.plugins = discovered_plugins.clone(); + pkg.decorator_registrations = decorator_regs.clone(); + } + manifest.mark_explicit(package_name_full); + + let r2x_dependencies: Vec = dependencies + .iter() + .filter(|dep| utils::looks_like_r2x_plugin(dep)) + .cloned() + .collect(); + + { + let pkg = manifest.get_or_create_package(package_name_full); + pkg.dependencies = r2x_dependencies.clone(); + } + + for dep in r2x_dependencies { + manifest.add_dependency(package_name_full, &dep); + + let has_dep_cached = manifest.packages.iter().any(|p| p.name == dep); + let (dep_plugins, dep_decorators) = if has_dep_cached && !no_cache { + if let Some(pkg) = manifest.packages.iter().find(|p| p.name == dep) { + (pkg.plugins.clone(), pkg.decorator_registrations.clone()) + } else { + (Vec::new(), Vec::new()) + } + } else { + match find_package_path(&dep) { + Ok(dep_path) => match AstDiscovery::discover_plugins( + &dep_path, + &dep, + venv_path.as_deref(), + None, + ) { + Ok(result) => result, + Err(e) => { + logger::warn(&format!( + "Failed to discover plugins from dependency '{}': {}", + &dep, e + )); + (Vec::new(), Vec::new()) + } + }, + Err(e) => { + logger::warn(&format!( + "Failed to locate dependency package '{}': {}", + &dep, e + )); + (Vec::new(), Vec::new()) + } + } + }; + + if dep_plugins.is_empty() { + continue; + } + + let dep_count = dep_plugins.len(); + { + let dep_pkg = manifest.get_or_create_package(&dep); + dep_pkg.plugins = dep_plugins; + dep_pkg.decorator_registrations = dep_decorators; + } + manifest.mark_dependency(&dep, package_name_full); + total_plugins += dep_count; + } + + // Save the updated manifest with all plugins (explicit + dependencies) + manifest + .save() + .map_err(|e| format!("Failed to save manifest: {}", e))?; + + Ok(total_plugins) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_looks_like_r2x_plugin() { + assert!(utils::looks_like_r2x_plugin("r2x-reeds")); + assert!(utils::looks_like_r2x_plugin("r2x-plexos")); + assert!(!utils::looks_like_r2x_plugin("r2x-core")); + assert!(!utils::looks_like_r2x_plugin("numpy")); + } +} diff --git a/crates/r2x-cli/src/plugins/install.rs b/crates/r2x-cli/src/plugins/install.rs new file mode 100644 index 0000000..b7f992e --- /dev/null +++ b/crates/r2x-cli/src/plugins/install.rs @@ -0,0 +1,71 @@ +use crate::logger; +use std::process::Command; + +/// Query package info via a single pip show call. +/// Returns (version, dependencies) tuple. +/// Returns (None, empty_vec) on any error (best-effort, non-fatal). +pub fn get_package_info( + uv_path: &str, + python_path: &str, + package: &str, +) -> Result<(Option, Vec), String> { + let show_output = Command::new(uv_path) + .args(["pip", "show", "--python", python_path, package]) + .output() + .map_err(|e| { + logger::debug(&format!( + "Failed to query package info for '{}': {}", + package, e + )); + format!("Failed to query package info: {}", e) + })?; + + if !show_output.status.success() { + logger::debug(&format!( + "pip show failed for package '{}' with status: {}", + package, show_output.status + )); + return Err("pip show failed".to_string()); + } + + let stdout = String::from_utf8_lossy(&show_output.stdout); + let mut version = None; + let mut dependencies = Vec::new(); + + for line in stdout.lines() { + if line.starts_with("Version:") { + version = Some(line.trim_start_matches("Version:").trim().to_string()); + } + if line.starts_with("Requires:") { + let requires_str = line.trim_start_matches("Requires:").trim(); + if !requires_str.is_empty() { + for dep in requires_str.split(',') { + let dep_name = dep.trim(); + if let Some(pkg_name) = dep_name.split(['>', '<', '=', '!', '~']).next() { + let clean_name = pkg_name.trim(); + if !clean_name.is_empty() { + dependencies.push(clean_name.to_string()); + } + } + } + } + } + } + + logger::debug(&format!( + "Package '{}': version={:?}, {} dependencies", + package, + version, + dependencies.len() + )); + + Ok((version, dependencies)) +} + +#[cfg(test)] +mod tests { + #[test] + fn test_get_package_info() { + // Test package info extraction + } +} diff --git a/crates/r2x-cli/src/plugins/mod.rs b/crates/r2x-cli/src/plugins/mod.rs new file mode 100644 index 0000000..2212a77 --- /dev/null +++ b/crates/r2x-cli/src/plugins/mod.rs @@ -0,0 +1,22 @@ +// Core plugin infrastructure modules +pub mod config; +pub mod discovery; +pub mod install; +pub mod package_resolver; +pub mod package_spec; +pub mod utils; + +// Re-export public functions from core infrastructure +pub use install::get_package_info; +pub use package_resolver::find_package_path; +// Re-export AstDiscovery from new location +pub use crate::r2x_ast::AstDiscovery; + +#[cfg(test)] +mod tests { + + #[test] + fn test_plugins_module() { + // Module-level tests + } +} diff --git a/crates/r2x-cli/src/plugins/package_resolver.rs b/crates/r2x-cli/src/plugins/package_resolver.rs new file mode 100644 index 0000000..70a51fd --- /dev/null +++ b/crates/r2x-cli/src/plugins/package_resolver.rs @@ -0,0 +1,94 @@ +//! Package path resolution for installed packages +//! +//! Handles locating installed packages in virtual environments, +//! including support for UV editable installs via .pth files. + +use std::path::PathBuf; + +/// Find the path to an installed package +pub fn find_package_path(package_name_full: &str) -> Result { + let config = crate::config_manager::Config::load() + .map_err(|e| format!("Failed to load config: {}", e))?; + + let normalized_package_name = package_name_full.replace('-', "_"); + + // First, try to find the package via UV's .pth file cache (for editable/local installs) + if let Ok(uv_cache_path) = try_find_package_via_pth(&normalized_package_name) { + return Ok(uv_cache_path); + } + + // Fallback: search in site-packages (for normally installed packages) + let venv_path = PathBuf::from(config.get_venv_path()); + let lib_dir = venv_path.join("lib"); + + let python_version_dir = std::fs::read_dir(&lib_dir) + .map_err(|e| format!("Failed to read lib directory: {}", e))? + .filter_map(|e| e.ok()) + .find(|e| e.file_name().to_string_lossy().starts_with("python")) + .ok_or_else(|| "No python directory found in venv".to_string())?; + + let site_packages = python_version_dir.path().join("site-packages"); + + let package_dir = std::fs::read_dir(&site_packages) + .map_err(|e| format!("Failed to read site-packages: {}", e))? + .filter_map(|e| e.ok()) + .find(|e| { + let name = e.file_name().to_string_lossy().to_string(); + name == normalized_package_name + || name.starts_with(&format!("{}-", normalized_package_name)) + }) + .ok_or_else(|| format!("Package '{}' not found in site-packages", package_name_full))?; + + Ok(package_dir.path()) +} + +/// Find package path via UV's .pth file cache (for editable/local installs) +fn try_find_package_via_pth(normalized_package_name: &str) -> Result { + // Look for .pth file in UV cache directory + // Pattern: ~/.cache/uv/archive-v0//.pth + let cache_dir = if let Some(home) = dirs::home_dir() { + home.join(".cache").join("uv").join("archive-v0") + } else { + return Err("Could not determine home directory".to_string()); + }; + + if !cache_dir.exists() { + return Err("UV cache not found".to_string()); + } + + // Search through all hash directories in the UV cache + let hash_dirs = std::fs::read_dir(&cache_dir) + .map_err(|e| format!("Failed to read UV cache directory: {}", e))?; + + for hash_entry in hash_dirs { + let hash_entry = match hash_entry { + Ok(e) => e, + Err(_) => continue, + }; + + let hash_path = hash_entry.path(); + if !hash_path.is_dir() { + continue; + } + + // Look for .pth file matching the package name + let pth_path = hash_path.join(format!("{}.pth", normalized_package_name)); + if pth_path.exists() { + // Read the path from the .pth file + match std::fs::read_to_string(&pth_path) { + Ok(content) => { + let package_path = content.trim(); + if !package_path.is_empty() { + return Ok(PathBuf::from(package_path)); + } + } + Err(_) => continue, + } + } + } + + Err(format!( + "Package '{}' not found in UV cache", + normalized_package_name + )) +} diff --git a/src/plugins/package_spec.rs b/crates/r2x-cli/src/plugins/package_spec.rs similarity index 99% rename from src/plugins/package_spec.rs rename to crates/r2x-cli/src/plugins/package_spec.rs index 76d7988..a12b3a5 100644 --- a/src/plugins/package_spec.rs +++ b/crates/r2x-cli/src/plugins/package_spec.rs @@ -74,7 +74,8 @@ pub fn extract_package_name(package: &str) -> Result { // For URLs, extract the repository name if pkg.contains("://") || pkg.starts_with("git@") { // Extract last path component and remove .git suffix - Ok(pkg.split('/') + Ok(pkg + .split('/') .next_back() .unwrap_or(pkg) .trim_end_matches(".git") diff --git a/crates/r2x-cli/src/plugins/utils.rs b/crates/r2x-cli/src/plugins/utils.rs new file mode 100644 index 0000000..c121fb3 --- /dev/null +++ b/crates/r2x-cli/src/plugins/utils.rs @@ -0,0 +1,6 @@ +//! Shared utility functions for plugin discovery + +/// Check if a dependency looks like an r2x plugin (exclude the shared runtime) +pub fn looks_like_r2x_plugin(dep: &str) -> bool { + dep.starts_with("r2x-") && dep != "r2x-core" +} diff --git a/crates/r2x-cli/tests/fixtures/r2x.toml b/crates/r2x-cli/tests/fixtures/r2x.toml new file mode 100644 index 0000000..5fbc9ac --- /dev/null +++ b/crates/r2x-cli/tests/fixtures/r2x.toml @@ -0,0 +1,2 @@ +cache_path = "/tmp/r2x-cache" +uv_path = "/Users/psanchez/.local/bin/uv" diff --git a/crates/r2x-cli/tests/integration.rs b/crates/r2x-cli/tests/integration.rs new file mode 100644 index 0000000..4a4f28b --- /dev/null +++ b/crates/r2x-cli/tests/integration.rs @@ -0,0 +1,73 @@ +//! Integration tests for r2x + +use assert_cmd::{cargo::cargo_bin_cmd, Command}; +use predicates::prelude::*; +use std::path::PathBuf; + +fn fixture_config_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("r2x.toml") +} + +fn r2x_cmd() -> Command { + let mut cmd = cargo_bin_cmd!("r2x"); + cmd.env("R2X_CONFIG", fixture_config_path()); + cmd +} + +#[test] +fn test_version() { + r2x_cmd() + .arg("--version") + .assert() + .success() + .stdout(predicate::str::contains("r2x")); +} + +#[test] +fn test_help() { + r2x_cmd() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("R2X is a CLI tool")); +} + +#[test] +fn test_list_plugins_no_plugins() { + r2x_cmd().arg("list").assert().success(); +} + +#[test] +fn test_invalid_command() { + r2x_cmd().arg("invalid").assert().failure(); +} + +#[test] +fn test_plugins_help() { + r2x_cmd() + .args(["run", "plugin", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Usage: r2x run plugin")); +} + +#[test] +fn test_config_show() { + r2x_cmd() + .args(["config", "show"]) + .assert() + .success() + .stdout(predicate::str::contains("Configuration:")); +} + +#[test] +fn test_config_get() { + r2x_cmd() + .args(["config", "path"]) + .assert() + .success() + .stdout(predicate::str::contains("r2x.toml")); +} diff --git a/tests/tests/integration_tests.rs b/crates/r2x-cli/tests/tests/integration_tests.rs similarity index 68% rename from tests/tests/integration_tests.rs rename to crates/r2x-cli/tests/tests/integration_tests.rs index 642339d..531e83a 100644 --- a/tests/tests/integration_tests.rs +++ b/crates/r2x-cli/tests/tests/integration_tests.rs @@ -10,19 +10,6 @@ fn test_config_show() { .stdout(predicate::str::contains("Configuration")); } -// NOTE: test_config_set is disabled because it writes to the user's actual config file -// which can corrupt it during test runs. A proper fix would require using temp directories -// via environment variables or dependency injection. -// -// #[test] -// fn test_config_set() { -// let mut cmd = cargo_bin_cmd!("r2x"); -// cmd.arg("config").arg("set").arg("cache-path").arg("test-value"); -// cmd.assert() -// .success() -// .stdout(predicate::str::contains("Set cache-path = test-value")); -// } - #[test] fn test_plugins_list() { let mut cmd = cargo_bin_cmd!("r2x"); diff --git a/crates/r2x-cli/tests/unit.rs b/crates/r2x-cli/tests/unit.rs new file mode 100644 index 0000000..aa5b36e --- /dev/null +++ b/crates/r2x-cli/tests/unit.rs @@ -0,0 +1,24 @@ +//! Unit tests for r2x modules + +// #[test] +// fn test_plugin_registry_is_empty() { +// use std::collections::HashMap; +// let registry = r2x::python::plugin::PluginRegistry { +// parsers: HashMap::new(), +// exporters: HashMap::new(), +// modifiers: HashMap::new(), +// filters: HashMap::new(), +// }; +// +// assert!(registry.is_empty()); +// } + +// #[test] +// fn test_venv_path_helpers() { +// let venv_path = r2x::python::venv::get_venv_path(); +// assert!(venv_path.is_ok()); +// +// let path = venv_path.unwrap(); +// assert!(path.to_string_lossy().contains("r2x")); +// assert!(path.to_string_lossy().contains("venv")); +// } diff --git a/wix/main.wxs b/crates/r2x-cli/wix/main.wxs similarity index 100% rename from wix/main.wxs rename to crates/r2x-cli/wix/main.wxs diff --git a/crates/r2x-config/Cargo.toml b/crates/r2x-config/Cargo.toml new file mode 100644 index 0000000..8c5bb7e --- /dev/null +++ b/crates/r2x-config/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "r2x-config" +version = "0.0.1" +edition = { workspace = true } +authors = { workspace = true } +repository = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } +rust-version = { workspace = true } + +description = "Configuration management for the r2x CLI" +publish = false + +[dependencies] +dirs = "6.0" +serde = { version = "1.0", features = ["derive"] } +toml = { version = "0.9", features = ["preserve_order"] } +which = "8.0.0" diff --git a/src/config_manager.rs b/crates/r2x-config/src/lib.rs similarity index 94% rename from src/config_manager.rs rename to crates/r2x-config/src/lib.rs index 9e6ff0d..90d59eb 100644 --- a/src/config_manager.rs +++ b/crates/r2x-config/src/lib.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +use std::process::Command; use which::which; #[derive(Serialize, Deserialize, Debug, Clone, Default)] @@ -249,16 +250,16 @@ impl Config { self.uv_path = None; } - match which("uv") { - Ok(path) => { - let path_str = path.to_string_lossy().trim().to_string(); - self.uv_path = Some(path_str.clone()); - self.save()?; - return Ok(path_str); - } - Err(_) => { - return Err("uv is not installed. Please install it from: https://docs.astral.sh/uv/getting-started/installation/".into()) - } + if let Ok(path) = which("uv") { + let path_str = path.to_string_lossy().trim().to_string(); + self.uv_path = Some(path_str.clone()); + self.save()?; + return Ok(path_str); + } + + #[cfg(target_os = "windows")] + { + return Err("uv is not installed. Please install it from: https://docs.astral.sh/uv/getting-started/installation/".into()); } #[cfg(not(target_os = "windows"))] @@ -287,7 +288,12 @@ impl Config { } } - Err("Failed to locate uv after installation. Verify that ~/.local/bin or ~/.cargo/bin is in your PATH".into()) + return Err("Failed to locate uv after installation. Verify that ~/.local/bin or ~/.cargo/bin is in your PATH".into()); + } + + #[cfg(target_os = "windows")] + { + Err("uv is not installed. Please install it from: https://docs.astral.sh/uv/getting-started/installation/".into()) } } diff --git a/crates/r2x-logger/Cargo.toml b/crates/r2x-logger/Cargo.toml new file mode 100644 index 0000000..47ea8a1 --- /dev/null +++ b/crates/r2x-logger/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "r2x-logger" +version = "0.0.1" +edition = { workspace = true } +authors = { workspace = true } +repository = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } +rust-version = { workspace = true } + +description = "Logging utilities for the r2x CLI" +publish = false + +[dependencies] +chrono = { version = "0.4", features = ["serde"] } +colored = "3.0" +dirs = "6.0" +indicatif = "0.18" diff --git a/src/logger.rs b/crates/r2x-logger/src/lib.rs similarity index 98% rename from src/logger.rs rename to crates/r2x-logger/src/lib.rs index 559a996..777966e 100644 --- a/src/logger.rs +++ b/crates/r2x-logger/src/lib.rs @@ -142,9 +142,9 @@ pub fn success(message: &str) { /// Log a step message (important user-facing step) pub fn step(message: &str) { if get_verbosity() >= 2 { - eprintln!("TRACE {}", message); + eprintln!("TRACE: {}", message); } - write_to_log(&format!("STEP {}", message)); + write_to_log(&format!("STEP: {}", message)); } /// Capture command output and log it @@ -224,7 +224,7 @@ pub fn spinner_success(message: &str) { } } // Show success message with checkmark - eprintln!(" {} {}", "✔".green().bold(), message); + eprintln!("{} {}", "✔".green().bold(), message); } /// Stop the spinner with an error message diff --git a/crates/r2x-manifest/Cargo.toml b/crates/r2x-manifest/Cargo.toml new file mode 100644 index 0000000..2a1bc29 --- /dev/null +++ b/crates/r2x-manifest/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "r2x-manifest" +version = "0.0.1" +edition = { workspace = true } +authors = { workspace = true } +repository = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } +rust-version = { workspace = true } + +description = "Manifest management utilities for the r2x CLI" +publish = false + +[dependencies] +anyhow = "1.0" +chrono = { version = "0.4", features = ["serde"] } +dirs = "6.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = { version = "0.9", features = ["preserve_order"] } +tracing = "0.1" +thiserror = "2.0" + +[dev-dependencies] +tempfile = "3.8" diff --git a/crates/r2x-manifest/src/errors.rs b/crates/r2x-manifest/src/errors.rs new file mode 100644 index 0000000..70ce373 --- /dev/null +++ b/crates/r2x-manifest/src/errors.rs @@ -0,0 +1,18 @@ +use std::io; +use thiserror::Error; + +/// Errors that can occur during plugin manifest operations +#[derive(Error, Debug)] +pub enum ManifestError { + #[error("IO error: {0}")] + Io(#[from] io::Error), + + #[error("Failed to parse manifest: {0}")] + Parse(#[from] toml::de::Error), + + #[error("Failed to serialize manifest: {0}")] + Serialize(#[from] toml::ser::Error), + + #[error("Invalid plugin: {0}")] + InvalidPlugin(String), +} diff --git a/crates/r2x-manifest/src/lib.rs b/crates/r2x-manifest/src/lib.rs new file mode 100644 index 0000000..c9cb4c2 --- /dev/null +++ b/crates/r2x-manifest/src/lib.rs @@ -0,0 +1,30 @@ +//! R2X Manifest Management +//! +//! This module handles all manifest types and operations for the r2x plugin system. +//! It provides the core types for managing plugin metadata from discovery and AST analysis. +//! +//! The manifest is stored in TOML format and contains comprehensive metadata about +//! installed plugins, their configurations, and decorator registrations. + +pub mod errors; +pub mod manifest; +pub mod manifest_writer; +pub mod package_discovery; +pub mod runtime; +pub mod types; + +// Re-export main types for convenience +pub use runtime::{build_runtime_bindings, RuntimeBindings}; +pub use types::{ + CallableMetadata, ConfigMetadata, ConstructorArg, DecoratorRegistration, DiscoveryPlugin, + FunctionParameter, FunctionSignature, Manifest, Metadata, Package, ParameterEntry, + ParameterMetadata, Plugin, ResolvedReference, UpgraderMetadata, VarArgType, +}; + +pub use errors::ManifestError; + +// Re-export manifest writer utilities for custom paths (testing) +pub use manifest_writer::{read_from_path, write_to_path}; + +// Re-export package discovery for convenience +pub use package_discovery::{parse_entry_points, DiscoveredPackage, PackageDiscoverer}; diff --git a/crates/r2x-manifest/src/manifest.rs b/crates/r2x-manifest/src/manifest.rs new file mode 100644 index 0000000..7c6eed4 --- /dev/null +++ b/crates/r2x-manifest/src/manifest.rs @@ -0,0 +1,374 @@ +//! Manifest operations - loading, saving, and business logic +//! +//! This module provides the core operations for managing the r2x plugin manifest, +//! including CRUD operations, dependency tracking, and persistence. + +use super::types::{Manifest, Metadata, Package}; +use crate::errors::ManifestError; +use std::path::PathBuf; + +impl Manifest { + /// Get the default path to the manifest file + pub fn path() -> PathBuf { + // On Unix/macOS: use ~/.cache/r2x/manifest.toml + // On Windows: use AppData/Local/r2x/manifest.toml + #[cfg(not(target_os = "windows"))] + { + dirs::home_dir() + .expect("Could not determine home directory") + .join(".cache") + .join("r2x") + .join("manifest.toml") + } + + #[cfg(target_os = "windows")] + { + dirs::cache_dir() + .expect("Could not determine cache directory") + .join("r2x") + .join("manifest.toml") + } + } + + /// Load manifest from default location, returning empty manifest if file doesn't exist + pub fn load() -> Result { + let path = Self::path(); + if !path.exists() { + return Ok(Manifest { + metadata: Metadata { + version: "1.0".to_string(), + generated_at: chrono::Utc::now().to_rfc3339(), + uv_lock_path: None, + }, + packages: Vec::new(), + }); + } + + let content = std::fs::read_to_string(&path)?; + let manifest: Manifest = toml::from_str(&content)?; + Ok(manifest) + } + + /// Save manifest to default location + pub fn save(&self) -> Result<(), ManifestError> { + let path = Self::path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let content = toml::to_string_pretty(self)?; + std::fs::write(&path, content)?; + Ok(()) + } + + /// Clear all packages and save + pub fn clear(&mut self) -> Result<(), ManifestError> { + self.packages.clear(); + self.save() + } + + /// Find or create a package in the manifest + pub fn get_or_create_package(&mut self, name: &str) -> &mut Package { + if !self.packages.iter().any(|p| p.name == name) { + self.packages.push(Package { + name: name.to_string(), + entry_points_dist_info: String::new(), + editable_install: false, + pth_file: None, + resolved_source_path: None, + install_type: None, + installed_by: Vec::new(), + dependencies: Vec::new(), + plugins: Vec::new(), + decorator_registrations: Vec::new(), + }); + } + self.packages.iter_mut().find(|p| p.name == name).unwrap() + } + + /// Remove a package from the manifest + pub fn remove_package(&mut self, name: &str) -> bool { + let initial_len = self.packages.len(); + self.packages.retain(|p| p.name != name); + self.packages.len() < initial_len + } + + /// Remove all plugins belonging to a package from the manifest + pub fn remove_plugins_by_package(&mut self, package_name: &str) -> usize { + let mut count = 0; + for pkg in &mut self.packages { + if pkg.name == package_name { + count = pkg.plugins.len(); + pkg.plugins.clear(); + } + } + count + } + + /// Remove decorator registrations for a package + pub fn remove_decorator_registrations(&mut self, package_name: &str) -> bool { + for pkg in &mut self.packages { + if pkg.name == package_name { + let had_regs = !pkg.decorator_registrations.is_empty(); + pkg.decorator_registrations.clear(); + return had_regs; + } + } + false + } + + /// List all plugins (compatibility method) - returns (plugin_name, package_name) tuples + pub fn list_plugins(&self) -> Vec<(String, String)> { + self.packages + .iter() + .flat_map(|pkg| { + pkg.plugins + .iter() + .map(move |plugin| (plugin.name.clone(), pkg.name.clone())) + }) + .collect() + } + + /// Check if manifest has no packages + pub fn is_empty(&self) -> bool { + self.packages.is_empty() + } + + /// List all plugins across all packages (compatibility helper) + pub fn list_all_plugins(&self) -> Vec<(String, String)> { + self.packages + .iter() + .flat_map(|pkg| { + pkg.plugins + .iter() + .map(move |plugin| (plugin.name.clone(), pkg.name.clone())) + }) + .collect() + } + + /// Count total plugins across all packages + pub fn total_plugin_count(&self) -> usize { + self.packages.iter().map(|pkg| pkg.plugins.len()).sum() + } + + /// Mark a package as explicitly installed + pub fn mark_explicit(&mut self, package_name: &str) { + if let Some(pkg) = self.packages.iter_mut().find(|p| p.name == package_name) { + pkg.install_type = Some("explicit".to_string()); + } + } + + /// Mark a package as a dependency of another package + pub fn mark_dependency(&mut self, package_name: &str, installed_by: &str) { + if let Some(pkg) = self.packages.iter_mut().find(|p| p.name == package_name) { + pkg.install_type = Some("dependency".to_string()); + if !pkg.installed_by.contains(&installed_by.to_string()) { + pkg.installed_by.push(installed_by.to_string()); + } + } + } + + /// Record that a package depends on another package + pub fn add_dependency(&mut self, package_name: &str, dependency: &str) { + if let Some(pkg) = self.packages.iter_mut().find(|p| p.name == package_name) { + if !pkg.dependencies.contains(&dependency.to_string()) { + pkg.dependencies.push(dependency.to_string()); + } + } + } + + /// Remove a package and its dependencies if no other packages depend on them + /// Returns list of packages removed + pub fn remove_package_with_deps(&mut self, package_name: &str) -> Vec { + let mut removed = Vec::new(); + + // Find the package and its dependencies + let dependencies = if let Some(pkg) = self.packages.iter().find(|p| p.name == package_name) + { + pkg.dependencies.clone() + } else { + return removed; + }; + + // Remove the main package + if self.remove_package(package_name) { + removed.push(package_name.to_string()); + } + + // Check each dependency + for dep in dependencies { + // Remove this package from the dependency's installed_by list + if let Some(dep_pkg) = self.packages.iter_mut().find(|p| p.name == dep) { + dep_pkg.installed_by.retain(|pkg| pkg != package_name); + + // If no other packages depend on it, remove it + if dep_pkg.installed_by.is_empty() + && dep_pkg.install_type.as_deref() == Some("dependency") + { + if self.remove_package(&dep) { + removed.push(dep); + } + } + } + } + + removed + } + + /// Check if a package can be safely removed (has no dependents) + pub fn can_remove_package(&self, package_name: &str) -> bool { + // Check if any other package depends on this one + !self + .packages + .iter() + .any(|pkg| pkg.dependencies.contains(&package_name.to_string())) + } + + /// Get all packages that depend on the given package + pub fn get_dependents(&self, package_name: &str) -> Vec { + self.packages + .iter() + .filter(|pkg| pkg.dependencies.contains(&package_name.to_string())) + .map(|pkg| pkg.name.clone()) + .collect() + } + + /// Serialize this Manifest to a JSON string + pub fn to_json_string(&self) -> String { + serde_json::to_string_pretty(&self).unwrap_or_else(|_| "{}".to_string()) + } + + /// Return the manifest JSON for CLI/UI consumers + pub fn get_manifest_json() -> String { + match Manifest::load() { + Ok(manifest) => manifest.to_json_string(), + Err(_) => "{}".to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_manifest_default() { + let manifest = Manifest::default(); + assert!(manifest.is_empty()); + assert_eq!(manifest.metadata.version, "1.0"); + } + + #[test] + fn test_get_or_create_package() { + let mut manifest = Manifest::default(); + + let pkg = manifest.get_or_create_package("r2x-test"); + pkg.install_type = Some("explicit".to_string()); + + assert_eq!(manifest.packages.len(), 1); + assert_eq!(manifest.packages[0].name, "r2x-test"); + } + + #[test] + fn test_remove_package() { + let mut manifest = Manifest::default(); + manifest.get_or_create_package("r2x-test"); + + assert_eq!(manifest.packages.len(), 1); + assert!(manifest.remove_package("r2x-test")); + assert_eq!(manifest.packages.len(), 0); + } + + #[test] + fn test_dependency_tracking() { + let mut manifest = Manifest::default(); + + // Create main package + manifest.get_or_create_package("r2x-main"); + manifest.mark_explicit("r2x-main"); + + // Create dependency + manifest.get_or_create_package("r2x-dep"); + manifest.mark_dependency("r2x-dep", "r2x-main"); + manifest.add_dependency("r2x-main", "r2x-dep"); + + // Verify structure + let main_pkg = manifest + .packages + .iter() + .find(|p| p.name == "r2x-main") + .unwrap(); + assert_eq!(main_pkg.install_type, Some("explicit".to_string())); + assert_eq!(main_pkg.dependencies, vec!["r2x-dep"]); + + let dep_pkg = manifest + .packages + .iter() + .find(|p| p.name == "r2x-dep") + .unwrap(); + assert_eq!(dep_pkg.install_type, Some("dependency".to_string())); + assert_eq!(dep_pkg.installed_by, vec!["r2x-main"]); + } + + #[test] + fn test_remove_with_deps() { + let mut manifest = Manifest::default(); + + // Setup: main package with dependency + manifest.get_or_create_package("r2x-main"); + manifest.mark_explicit("r2x-main"); + manifest.get_or_create_package("r2x-dep"); + manifest.mark_dependency("r2x-dep", "r2x-main"); + manifest.add_dependency("r2x-main", "r2x-dep"); + + // Remove main package + let removed = manifest.remove_package_with_deps("r2x-main"); + + // Both should be removed + assert_eq!(removed.len(), 2); + assert!(removed.contains(&"r2x-main".to_string())); + assert!(removed.contains(&"r2x-dep".to_string())); + assert!(manifest.is_empty()); + } + + #[test] + fn test_shared_dependency_not_removed() { + let mut manifest = Manifest::default(); + + // Setup: two packages sharing a dependency + manifest.get_or_create_package("r2x-main1"); + manifest.mark_explicit("r2x-main1"); + manifest.get_or_create_package("r2x-main2"); + manifest.mark_explicit("r2x-main2"); + manifest.get_or_create_package("r2x-shared"); + manifest.mark_dependency("r2x-shared", "r2x-main1"); + manifest.mark_dependency("r2x-shared", "r2x-main2"); + manifest.add_dependency("r2x-main1", "r2x-shared"); + manifest.add_dependency("r2x-main2", "r2x-shared"); + + // Remove only main1 + let removed = manifest.remove_package_with_deps("r2x-main1"); + + // Only main1 should be removed, shared is still used by main2 + assert_eq!(removed.len(), 1); + assert_eq!(removed[0], "r2x-main1"); + assert_eq!(manifest.packages.len(), 2); + + let shared = manifest + .packages + .iter() + .find(|p| p.name == "r2x-shared") + .unwrap(); + assert_eq!(shared.installed_by, vec!["r2x-main2"]); + } + + #[test] + fn test_clear_manifest() { + let mut manifest = Manifest::default(); + manifest.get_or_create_package("r2x-test"); + + assert!(!manifest.is_empty()); + manifest.packages.clear(); + assert!(manifest.is_empty()); + } +} diff --git a/crates/r2x-manifest/src/manifest_writer.rs b/crates/r2x-manifest/src/manifest_writer.rs new file mode 100644 index 0000000..0119ce6 --- /dev/null +++ b/crates/r2x-manifest/src/manifest_writer.rs @@ -0,0 +1,118 @@ +//! Manifest writer utilities for custom paths +//! +//! This module provides helper functions for writing/reading manifests +//! to/from custom paths, primarily used in testing scenarios. +//! +//! For normal operations, use the `Manifest` methods in `manifest.rs` +//! which work with the default manifest location. + +use anyhow::Result; +use std::fs; +use std::path::Path; +use tracing::{debug, info}; + +use super::types::Manifest; + +/// Write manifest to a custom path (primarily for testing) +pub fn write_to_path(manifest: &Manifest, output_path: &Path) -> Result<()> { + debug!("Writing manifest to custom path: {:?}", output_path); + + let toml_string = toml::to_string_pretty(manifest)?; + fs::write(output_path, &toml_string)?; + + info!("Manifest written successfully to: {:?}", output_path); + info!("Total packages: {}", manifest.packages.len()); + + Ok(()) +} + +/// Read manifest from a custom path (primarily for testing) +pub fn read_from_path(manifest_path: &Path) -> Result { + debug!("Reading manifest from custom path: {:?}", manifest_path); + + let content = fs::read_to_string(manifest_path)?; + let manifest: Manifest = toml::from_str(&content)?; + + info!("Manifest loaded successfully"); + info!("Manifest version: {}", manifest.metadata.version); + info!("Generated at: {}", manifest.metadata.generated_at); + info!("Total packages: {}", manifest.packages.len()); + + Ok(manifest) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{ConstructorArg, DiscoveryPlugin, Metadata, Package}; + use tempfile::TempDir; + + #[test] + fn test_write_and_read_custom_path() { + let temp_dir = TempDir::new().unwrap(); + let manifest_path = temp_dir.path().join("test_manifest.toml"); + + let packages = vec![Package { + name: "r2x-example".to_string(), + entry_points_dist_info: "/path/to/entry_points.txt".to_string(), + editable_install: true, + pth_file: Some("/path/to/easy-install.pth".to_string()), + resolved_source_path: Some("/home/dev/r2x-example".to_string()), + install_type: Some("explicit".to_string()), + installed_by: Vec::new(), + dependencies: Vec::new(), + plugins: vec![DiscoveryPlugin { + name: "example-plugin".to_string(), + plugin_type: "ParserPlugin".to_string(), + constructor_args: vec![ + ConstructorArg { + name: "name".to_string(), + value: "example-plugin".to_string(), + arg_type: "string".to_string(), + }, + ConstructorArg { + name: "obj".to_string(), + value: "ExampleParser".to_string(), + arg_type: "class_reference".to_string(), + }, + ], + resolved_references: Vec::new(), + decorators: Vec::new(), + }], + decorator_registrations: vec![], + }]; + + let manifest = Manifest { + metadata: Metadata { + version: "1.0".to_string(), + generated_at: chrono::Utc::now().to_rfc3339(), + uv_lock_path: None, + }, + packages, + }; + + // Write to custom path + write_to_path(&manifest, &manifest_path).unwrap(); + + // Read back from custom path + let loaded = read_from_path(&manifest_path).unwrap(); + + assert_eq!(loaded.packages.len(), 1); + assert_eq!(loaded.packages[0].name, "r2x-example"); + assert_eq!(loaded.packages[0].editable_install, true); + assert_eq!(loaded.packages[0].plugins[0].name, "example-plugin"); + assert_eq!(loaded.packages[0].plugins[0].constructor_args.len(), 2); + } + + #[test] + fn test_version_preserved() { + let temp_dir = TempDir::new().unwrap(); + let manifest_path = temp_dir.path().join("manifest.toml"); + + let manifest = Manifest::default(); + write_to_path(&manifest, &manifest_path).unwrap(); + + let loaded = read_from_path(&manifest_path).unwrap(); + assert_eq!(loaded.metadata.version, "1.0"); + } +} diff --git a/crates/r2x-manifest/src/package_discovery.rs b/crates/r2x-manifest/src/package_discovery.rs new file mode 100644 index 0000000..d1467b5 --- /dev/null +++ b/crates/r2x-manifest/src/package_discovery.rs @@ -0,0 +1,281 @@ +use anyhow::{anyhow, Result}; +use std::fs; +use std::path::{Path, PathBuf}; +use tracing::{debug, info}; + +/// Information about a discovered r2x package +#[derive(Debug, Clone)] +pub struct DiscoveredPackage { + /// Distribution name (e.g., "r2x-reeds") + pub name: String, + /// Whether this was explicitly installed or a dependency + pub is_explicit: bool, + /// Root directory of the package + pub location: PathBuf, + /// Path to entry_points.txt in dist-info + pub entry_points_file: PathBuf, + /// Whether this is an editable install + pub is_editable: bool, + /// .pth file path if editable + pub pth_file: Option, + /// Resolved source path if editable + pub resolved_source_path: Option, +} + +/// Discovers r2x packages in a Python environment +pub struct PackageDiscoverer { + /// Site-packages directory + site_packages: PathBuf, +} + +impl PackageDiscoverer { + /// Create a new discovery instance for the given site-packages path + pub fn new(site_packages: PathBuf) -> Result { + debug!("Initializing package discovery for: {:?}", site_packages); + + if !site_packages.exists() { + return Err(anyhow!( + "Site-packages directory not found: {:?}", + site_packages + )); + } + + Ok(PackageDiscoverer { site_packages }) + } + + /// Discover all r2x-* packages in site-packages + pub fn discover_packages(&self) -> Result> { + debug!("Discovering r2x packages in: {:?}", self.site_packages); + + let mut packages = Vec::new(); + let entries = fs::read_dir(&self.site_packages)?; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + let file_name = entry.file_name(); + let file_name_str = file_name.to_string_lossy(); + + // Look for dist-info directories + if file_name_str.ends_with(".dist-info") { + if let Ok(pkg) = self.process_dist_info(&path, &file_name_str) { + debug!("Discovered package: {}", pkg.name); + packages.push(pkg); + } + } + } + + info!("Found {} r2x packages", packages.len()); + Ok(packages) + } + + /// Process a single .dist-info directory + fn process_dist_info( + &self, + dist_info_path: &Path, + dist_info_name: &str, + ) -> Result { + // Extract package name from dist-info (e.g., r2x_reeds-1.2.3.dist-info -> r2x-reeds) + let package_name = dist_info_name + .strip_suffix(".dist-info") + .ok_or_else(|| anyhow!("Invalid dist-info name: {}", dist_info_name))? + .split('-') + .next() + .ok_or_else(|| anyhow!("Cannot extract package name from: {}", dist_info_name))? + .replace('_', "-"); + + // Only process r2x-* packages except the shared runtime + if !package_name.starts_with("r2x-") || package_name == "r2x-core" { + return Err(anyhow!("Package is not an r2x plugin: {}", package_name)); + } + + debug!("Processing dist-info for: {}", package_name); + + // Check for entry_points.txt with r2x_plugin entry point + let entry_points_file = dist_info_path.join("entry_points.txt"); + if !entry_points_file.exists() { + return Err(anyhow!( + "No entry_points.txt found in: {:?}", + dist_info_path + )); + } + + // Verify it has r2x_plugin entry point + let entry_points_content = fs::read_to_string(&entry_points_file)?; + if !entry_points_content.contains("[r2x_plugin]") { + return Err(anyhow!( + "No [r2x_plugin] entry point found in: {}", + package_name + )); + } + + // Get package location (parent directory of dist-info) + let location = dist_info_path + .parent() + .ok_or_else(|| anyhow!("Cannot get parent of dist-info"))? + .to_path_buf(); + + // Check if it's an editable install + let (is_editable, pth_file, resolved_source_path) = + self.check_editable_install(&package_name, &location)?; + + Ok(DiscoveredPackage { + name: package_name, + is_explicit: true, // TODO: Read from installed.json to distinguish + location, + entry_points_file, + is_editable, + pth_file, + resolved_source_path, + }) + } + + /// Check if package is an editable install and resolve source path + #[allow(dead_code)] + fn check_editable_install( + &self, + package_name: &str, + _location: &Path, + ) -> Result<(bool, Option, Option)> { + // Look for .pth file in site-packages + let pth_pattern = format!("__{}-*__.pth", package_name.replace('-', "_")); + debug!("Looking for editable install marker: {}", pth_pattern); + + for entry in fs::read_dir(&self.site_packages)? { + let entry = entry?; + let path = entry.path(); + let file_name = entry.file_name(); + let file_name_str = file_name.to_string_lossy(); + + if file_name_str.ends_with(".pth") + && file_name_str.contains(&package_name.replace('-', "_")) + { + // Try to read the .pth file and resolve the actual source path + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(resolved_path) = self.resolve_pth_path(&content) { + debug!( + "Found editable install for {} at: {:?}", + package_name, resolved_path + ); + return Ok((true, Some(path), Some(resolved_path))); + } + } + } + } + + Ok((false, None, None)) + } + + /// Parse .pth file content and resolve the actual source path + #[allow(dead_code)] + fn resolve_pth_path(&self, content: &str) -> Result { + // .pth files can contain multiple lines, typically with import statements + // For editable installs, usually just contains a path + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + // Try to parse as a direct path + let path = PathBuf::from(line); + if path.exists() && path.is_dir() { + return Ok(path); + } + } + + Err(anyhow!("Cannot resolve path from .pth content")) + } +} + +/// Parse entry_points.txt and extract r2x_plugin entry point +pub fn parse_entry_points(entry_points_path: &Path) -> Result<(String, String)> { + let content = fs::read_to_string(entry_points_path)?; + let mut in_r2x_section = false; + let mut module = String::new(); + let mut function = String::new(); + + for line in content.lines() { + let line = line.trim(); + + if line == "[r2x_plugin]" { + in_r2x_section = true; + continue; + } + + if in_r2x_section { + if line.starts_with('[') { + // Entered another section + break; + } + + if line.is_empty() || line.starts_with('#') { + continue; + } + + // Parse "key = module:function" format + if let Some(eq_idx) = line.find('=') { + let value = line[eq_idx + 1..].trim(); + if let Some(colon_idx) = value.find(':') { + module = value[..colon_idx].trim().to_string(); + function = value[colon_idx + 1..].trim().to_string(); + break; + } + } + } + } + + if module.is_empty() || function.is_empty() { + return Err(anyhow!( + "No valid r2x_plugin entry point found in: {:?}", + entry_points_path + )); + } + + debug!("Parsed entry point: {}:{}", module, function); + Ok((module, function)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_entry_points() { + let content = r#"[r2x_plugin] +reeds = r2x_reeds.plugins:register_plugin + +[other] +something = some.module:function +"#; + + let temp_dir = std::env::temp_dir(); + let temp_file = temp_dir.join("test_entry_points.txt"); + fs::write(&temp_file, content).unwrap(); + + let result = parse_entry_points(&temp_file).unwrap(); + assert_eq!(result.0, "r2x_reeds.plugins"); + assert_eq!(result.1, "register_plugin"); + + let _ = fs::remove_file(&temp_file); + } + + #[test] + fn test_parse_entry_points_multiple_entries() { + let content = r#"[r2x_plugin] +reeds = r2x_reeds.plugins:register_plugin +other = other.module:func +"#; + + let temp_dir = std::env::temp_dir(); + let temp_file = temp_dir.join("test_entry_points2.txt"); + fs::write(&temp_file, content).unwrap(); + + let result = parse_entry_points(&temp_file).unwrap(); + // Should get the first one + assert_eq!(result.0, "r2x_reeds.plugins"); + assert_eq!(result.1, "register_plugin"); + + let _ = fs::remove_file(&temp_file); + } +} diff --git a/crates/r2x-manifest/src/runtime.rs b/crates/r2x-manifest/src/runtime.rs new file mode 100644 index 0000000..59ba2a7 --- /dev/null +++ b/crates/r2x-manifest/src/runtime.rs @@ -0,0 +1,88 @@ +use crate::types::{ + CallableMetadata, ConfigMetadata, ConstructorArg, DiscoveryPlugin, ParameterEntry, + ParameterMetadata, ResolvedReference, +}; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct RuntimeBindings { + pub callable: CallableMetadata, + pub config: Option, + pub call_method: Option, + pub io_type: Option, + pub requires_store: Option, +} + +pub fn build_runtime_bindings(plugin: &DiscoveryPlugin) -> Result { + let callable_arg = find_arg(plugin, "obj") + .ok_or_else(|| format!("Plugin '{}' missing 'obj' argument", plugin.name))?; + let callable_ref = find_reference(plugin, &callable_arg.value).ok_or_else(|| { + format!( + "Plugin '{}' missing resolved reference for '{}'", + plugin.name, callable_arg.value + ) + })?; + + let callable = CallableMetadata { + module: callable_ref.module.clone(), + name: callable_ref.name.clone(), + callable_type: callable_ref.ref_type.clone(), + return_annotation: callable_ref.return_annotation.clone(), + parameters: build_parameter_map(&callable_ref.parameters), + }; + + let config = find_arg(plugin, "config") + .and_then(|arg| find_reference(plugin, &arg.value)) + .map(|reference| ConfigMetadata { + module: reference.module.clone(), + name: reference.name.clone(), + return_annotation: reference.return_annotation.clone(), + parameters: build_parameter_map(&reference.parameters), + }); + + let call_method = find_arg(plugin, "call_method").map(|arg| arg.value.clone()); + let io_type = find_arg(plugin, "io_type").map(|arg| arg.value.clone()); + let requires_store = find_arg(plugin, "requires_store").and_then(|arg| parse_bool(&arg.value)); + + Ok(RuntimeBindings { + callable, + config, + call_method, + io_type, + requires_store, + }) +} + +fn find_arg<'a>(plugin: &'a DiscoveryPlugin, name: &str) -> Option<&'a ConstructorArg> { + plugin.constructor_args.iter().find(|arg| arg.name == name) +} + +fn find_reference<'a>(plugin: &'a DiscoveryPlugin, key: &str) -> Option<&'a ResolvedReference> { + plugin + .resolved_references + .iter() + .find(|reference| reference.key == key) +} + +fn build_parameter_map(entries: &[ParameterEntry]) -> HashMap { + let mut map = HashMap::new(); + for entry in entries { + map.insert( + entry.name.clone(), + ParameterMetadata { + annotation: entry.annotation.clone(), + default: entry.default.clone(), + is_required: entry.is_required, + }, + ); + } + map +} + +fn parse_bool(value: &str) -> Option { + match value.trim().to_lowercase().as_str() { + "true" => Some(true), + "false" => Some(false), + _ => None, + } +} diff --git a/crates/r2x-manifest/src/types.rs b/crates/r2x-manifest/src/types.rs new file mode 100644 index 0000000..59d0950 --- /dev/null +++ b/crates/r2x-manifest/src/types.rs @@ -0,0 +1,296 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Callable object metadata (parsed from obj JSON) +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CallableMetadata { + /// Python module path (e.g., "r2x_reeds.parser") + pub module: String, + + /// Callable name (e.g., "ReEDSParser") + pub name: String, + + /// Callable type: "class" or "function" + #[serde(rename = "type")] + pub callable_type: String, + + /// Return annotation (e.g., "None", "System") + #[serde(skip_serializing_if = "Option::is_none")] + pub return_annotation: Option, + + /// Parameters as a map of parameter name to metadata + #[serde(default)] + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub parameters: HashMap, +} + +/// Configuration schema metadata (parsed from config JSON) +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ConfigMetadata { + /// Config module path (e.g., "r2x_reeds.config") + pub module: String, + + /// Config class name (e.g., "ReEDSConfig") + pub name: String, + + /// Return annotation + #[serde(skip_serializing_if = "Option::is_none")] + pub return_annotation: Option, + + /// Config parameters as a map of parameter name to metadata + #[serde(default)] + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub parameters: HashMap, +} + +/// Parameter metadata for a callable or config +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ParameterMetadata { + /// Type annotation (e.g., "str | None", "int", "System") + #[serde(skip_serializing_if = "Option::is_none")] + pub annotation: Option, + + /// Default value as JSON string (e.g., "null", "true", "5", "\"base\"") + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + + /// Whether this parameter is required + pub is_required: bool, +} + +/// Upgrader-specific metadata +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct UpgraderMetadata { + /// Version strategy as JSON (kept as JSON for now due to complexity) + #[serde(skip_serializing_if = "Option::is_none")] + pub version_strategy_json: Option, + + /// Version reader as JSON (kept as JSON for now due to complexity) + #[serde(skip_serializing_if = "Option::is_none")] + pub version_reader_json: Option, + + /// Upgrade steps as JSON (kept as JSON for now due to complexity) + #[serde(skip_serializing_if = "Option::is_none")] + pub upgrade_steps_json: Option, +} + +/// Top-level manifest structure for plugin metadata from discovery +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Manifest { + pub metadata: Metadata, + #[serde(default)] + pub packages: Vec, +} + +/// Manifest metadata - version and generation info +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Metadata { + /// Schema version for future compatibility + pub version: String, + /// ISO 8601 timestamp when manifest was generated + pub generated_at: String, + /// Optional path to UV's lock file for version/dependency info + pub uv_lock_path: Option, +} + +impl Default for Manifest { + fn default() -> Self { + Manifest { + metadata: Metadata { + version: "1.0".to_string(), + generated_at: chrono::Utc::now().to_rfc3339(), + uv_lock_path: None, + }, + packages: Vec::new(), + } + } +} + +/// Represents a single Python package containing r2x plugins +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Package { + /// Distribution name (e.g., "r2x-reeds") + pub name: String, + /// Path to entry_points.txt in dist-info + pub entry_points_dist_info: String, + /// Whether this is an editable install (e.g., from `uv pip install -e`) + #[serde(default)] + pub editable_install: bool, + /// Path to .pth file (only set if editable_install=true) + pub pth_file: Option, + /// Resolved source path for editable installs + pub resolved_source_path: Option, + /// Installation type: "explicit" (user-installed) or "dependency" (auto-installed) + #[serde(skip_serializing_if = "Option::is_none")] + pub install_type: Option, + /// If install_type="dependency", which package(s) required this + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub installed_by: Vec, + /// R2X packages that this package depends on + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub dependencies: Vec, + /// Plugin definitions extracted from register_plugin() + #[serde(default)] + pub plugins: Vec, + /// Decorator-registered functions found in package + #[serde(default)] + pub decorator_registrations: Vec, +} + +/// Runtime-ready plugin metadata derived from discovery +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Plugin { + #[serde(skip_serializing_if = "Option::is_none")] + pub package_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub plugin_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub doc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub io_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub call_method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub requires_store: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub obj: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub upgrader: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub install_type: Option, + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub installed_by: Vec, +} + +/// Plugin definition extracted from Package.plugins in register_plugin() +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscoveryPlugin { + /// Plugin name as registered (e.g., "reeds-parser") + pub name: String, + /// Plugin type/class name (e.g., "ParserPlugin", "UpgraderPlugin") + pub plugin_type: String, + /// All constructor arguments from plugin instantiation + #[serde(default)] + pub constructor_args: Vec, + /// Resolved class/function references with full metadata + #[serde(default)] + pub resolved_references: Vec, + /// Decorators associated with this plugin + #[serde(default)] + pub decorators: Vec, +} + +/// Single constructor argument for plugin instantiation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConstructorArg { + /// Parameter name + pub name: String, + /// Raw value as string (preserved from source) + pub value: String, + /// Type category (string, class_reference, function_reference, enum_value, etc.) + pub arg_type: String, +} + +/// Resolved reference to a class or function with full metadata +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResolvedReference { + /// Key identifying which constructor_arg this resolves + pub key: String, + /// Type of reference: "class" or "function" + pub ref_type: String, + /// Python module path (e.g., "r2x_reeds.parser") + pub module: String, + /// Name of the class or function + pub name: String, + /// Source file path (relative to package root) + #[serde(skip_serializing_if = "Option::is_none")] + pub source_file: Option, + /// Parameters for this callable + #[serde(default)] + pub parameters: Vec, + /// Return type annotation (for functions) + #[serde(skip_serializing_if = "Option::is_none")] + pub return_annotation: Option, +} + +/// Parameter entry for resolved references (array-based for TOML compatibility) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParameterEntry { + /// Parameter name + pub name: String, + /// Type annotation + #[serde(skip_serializing_if = "Option::is_none")] + pub annotation: Option, + /// Default value + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + /// Whether this parameter is required + pub is_required: bool, +} + +/// Function registration via decorator (e.g., @ReEDSUpgrader.register_step(...)) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecoratorRegistration { + /// Class that provides the decorator (e.g., "ReEDSUpgrader") + pub decorator_class: String, + /// Decorator method name (e.g., "register_step") + pub decorator_method: String, + /// Function name being decorated + pub function_name: String, + /// Module containing the function + pub function_module: String, + /// Source file path (relative to package root) + pub source_file: Option, + /// Line number in source file + pub line_number: Option, + /// Arguments passed to the decorator + #[serde(default)] + pub decorator_args: toml::Table, + /// Function signature (parameters and return type) + pub function_signature: Option, +} + +/// Complete function signature extracted from source +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionSignature { + /// Return type as string (e.g., "None", "dict[str, Any]") + pub return_type: String, + /// Function parameters in order + #[serde(default)] + pub parameters: Vec, +} + +/// Single function parameter +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionParameter { + /// Parameter name + pub name: String, + /// Type annotation as string (preserved as-is from source) + pub param_type: String, + /// Default value if any + pub default: Option, + /// Whether this is a keyword-only argument + #[serde(default)] + pub is_keyword_only: bool, + /// Whether this is *args or **kwargs + pub is_var_arg: Option, +} + +/// Type of variable argument (*args or **kwargs) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum VarArgType { + Args, + Kwargs, +} + +// Manifest implementation moved to manifest.rs + +// Tests moved to manifest.rs diff --git a/crates/r2x-python/Cargo.toml b/crates/r2x-python/Cargo.toml new file mode 100644 index 0000000..0e9c545 --- /dev/null +++ b/crates/r2x-python/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "r2x-python" +version = "0.0.1" +edition = { workspace = true } +authors = { workspace = true } +repository = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } +rust-version = { workspace = true } + +description = "Python bridge for the r2x CLI" +publish = false + +[dependencies] +once_cell = "1.19" +pyo3 = { version = "0.27", features = ["auto-initialize", "abi3-py38"] } +serde_json = "1.0" +tracing = "0.1" +r2x-config = { path = "../r2x-config" } +r2x-logger = { path = "../r2x-logger" } +r2x-manifest = { path = "../r2x-manifest" } +thiserror = "2.0" diff --git a/crates/r2x-python/src/errors.rs b/crates/r2x-python/src/errors.rs new file mode 100644 index 0000000..e23ea10 --- /dev/null +++ b/crates/r2x-python/src/errors.rs @@ -0,0 +1,40 @@ +use std::io; +use std::path::PathBuf; +use thiserror::Error; + +/// Errors that can occur during Python bridge operations +#[derive(Error, Debug)] +pub enum BridgeError { + #[error("Python error: {0}")] + Python(String), + + #[error("Failed to import module '{0}': {1}")] + Import(String, String), + + #[error("Python venv not found or invalid at: {0}")] + VenvNotFound(PathBuf), + + #[error("r2x-core is not installed in the Python environment")] + R2XCoreNotInstalled, + + #[error("Failed to serialize/deserialize data: {0}")] + Serialization(String), + + #[error("Failed to initialize Python interpreter: {0}")] + Initialization(String), + + #[error("Plugin '{0}' not found")] + PluginNotFound(String), + + #[error("Invalid entry point format: {0}")] + InvalidEntryPoint(String), + + #[error("IO error: {0}")] + Io(#[from] io::Error), +} + +impl From for BridgeError { + fn from(err: pyo3::PyErr) -> Self { + BridgeError::Python(format!("{}", err)) + } +} diff --git a/src/python_bridge/initialization.rs b/crates/r2x-python/src/initialization.rs similarity index 70% rename from src/python_bridge/initialization.rs rename to crates/r2x-python/src/initialization.rs index 7a8cae6..b2ba1ed 100644 --- a/src/python_bridge/initialization.rs +++ b/crates/r2x-python/src/initialization.rs @@ -5,11 +5,12 @@ use super::utils::*; use crate::errors::BridgeError; -use crate::logger; use once_cell::sync::OnceCell; use pyo3::prelude::*; use pyo3::types::PyModule; -use std::path::{Path, PathBuf}; +use r2x_config::Config; +use r2x_logger as logger; +use std::path::PathBuf; use std::process::Command; pub struct Bridge {} @@ -36,21 +37,27 @@ impl Bridge { let python_path = configure_python_venv()?; + let mut config = Config::load() + .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))?; + let cache_path = config.ensure_cache_path().map_err(|e| { + BridgeError::Initialization(format!("Failed to ensure cache path: {}", e)) + })?; + logger::debug(&format!( "Initializing Python bridge with: {}", python_path.display() )); let pyo3_start = std::time::Instant::now(); - pyo3::prepare_freethreaded_python(); + pyo3::Python::initialize(); logger::debug(&format!( - "pyo3::prepare_freethreaded_python took: {:?}", + "pyo3::Python::initialize took: {:?}", pyo3_start.elapsed() )); // Enable Python bytecode generation for faster subsequent imports // This overrides PYTHONDONTWRITEBYTECODE if set in the environment - Python::with_gil(|py| { + pyo3::Python::attach(|py| { let sys = PyModule::import(py, "sys") .map_err(|e| BridgeError::Python(format!("Failed to import sys module: {}", e)))?; sys.setattr("dont_write_bytecode", false).map_err(|e| { @@ -61,11 +68,7 @@ impl Bridge { logger::debug("Enabled Python bytecode generation"); // Add site-packages from venv to sys.path so imports work as expected - let venv_path = PathBuf::from( - crate::config_manager::Config::load() - .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))? - .get_venv_path(), - ); + let venv_path = PathBuf::from(config.get_venv_path()); let lib_dir = venv_path.join(PYTHON_LIB_DIR); logger::debug(&format!( @@ -76,14 +79,27 @@ impl Bridge { if !lib_dir.exists() { return Err(BridgeError::VenvNotFound(venv_path.to_path_buf())); } - let site_packages = lib_dir.join(SITE_PACKAGES); + + // Find the python3.X directory inside lib/ + use std::fs; + let python_version_dir = fs::read_dir(&lib_dir) + .map_err(|e| { + BridgeError::Initialization(format!("Failed to read lib directory: {}", e)) + })? + .filter_map(|e| e.ok()) + .find(|e| e.file_name().to_string_lossy().starts_with("python")) + .ok_or_else(|| { + BridgeError::Initialization("No python3.X directory found in venv/lib".to_string()) + })?; + + let site_packages = python_version_dir.path().join(SITE_PACKAGES); logger::debug(&format!( "site_packages: {}, exists: {}", site_packages.display(), site_packages.exists() )); - Python::with_gil(|py| { + pyo3::Python::attach(|py| { let site = PyModule::import(py, "site") .map_err(|e| BridgeError::Python(format!("Failed to import site module: {}", e)))?; site.call_method1("addsitedir", (site_packages.to_str().unwrap(),)) @@ -105,6 +121,8 @@ impl Bridge { version_start.elapsed() )); + configure_python_cache(&cache_path)?; + // r2x_core is now installed during venv creation, so no need to check here // Configure Python loguru to write to the same log file as Rust @@ -119,14 +137,13 @@ impl Bridge { "Total bridge initialization took: {:?}", start_time.elapsed() )); - logger::info("Python bridge initialized successfully"); Ok(Bridge {}) } /// Configure Python loguru logging to integrate with Rust logger fn configure_python_logging() -> Result<(), BridgeError> { - let log_file = crate::logger::get_log_path_string(); - let verbosity = crate::logger::get_verbosity(); + let log_file = logger::get_log_path_string(); + let verbosity = logger::get_verbosity(); let log_level = match verbosity { 0 => "WARNING", 1 => "INFO", @@ -138,11 +155,22 @@ impl Bridge { let fmt = "[{time:YYYY-MM-DD HH:mm:ss}] [PYTHON] {level: <8} {message}"; // Check if Python logs should be shown on console - let enable_console = crate::logger::get_log_python(); + let enable_console = logger::get_log_python(); - Python::with_gil(|py| { - let logger_module = PyModule::import(py, "r2x_core.logger")?; - let setup_logging = logger_module.getattr("setup_logging")?; + logger::debug(&format!( + "Configuring Python logging with level={}, file={}, enable_console={}", + log_level, log_file, enable_console + )); + + pyo3::Python::attach(|py| { + let logger_module = PyModule::import(py, "r2x_core.logger").map_err(|e| { + logger::warn(&format!("Failed to import r2x_core.logger: {}", e)); + BridgeError::Import("r2x_core.logger".to_string(), format!("{}", e)) + })?; + let setup_logging = logger_module.getattr("setup_logging").map_err(|e| { + logger::warn(&format!("Failed to get setup_logging function: {}", e)); + BridgeError::Python(format!("setup_logging not found: {}", e)) + })?; let kwargs = pyo3::types::PyDict::new(py); kwargs.set_item("level", log_level)?; kwargs.set_item("log_file", &log_file)?; @@ -172,11 +200,11 @@ impl Bridge { /// 3. If missing or mismatched, updates config to the actual version /// 4. Logs warnings if there's a mismatch (indicates config was manually edited) fn detect_and_store_python_version() -> Result<(), BridgeError> { - let mut config = crate::config_manager::Config::load() + let mut config = Config::load() .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))?; // Get Python version from sys.version_info (the actual compiled version) - let version_str = Python::with_gil(|py| { + let version_str = pyo3::Python::attach(|py| { let sys = PyModule::import(py, "sys") .map_err(|e| BridgeError::Python(format!("Failed to import sys: {}", e)))?; let version_info = sys @@ -228,9 +256,63 @@ fn detect_and_store_python_version() -> Result<(), BridgeError> { Ok(()) } +fn configure_python_cache(cache_path: &str) -> Result<(), BridgeError> { + std::fs::create_dir_all(cache_path).map_err(|e| { + BridgeError::Initialization(format!("Failed to create cache directory: {}", e)) + })?; + std::env::set_var("R2X_CACHE_PATH", cache_path); + + let cache_path_escaped = cache_path.replace('\\', "\\\\"); + pyo3::Python::attach(|py| { + let patch_code = format!( + r#"from pathlib import Path +_R2X_CACHE_PATH = Path(r"{cache}") + +def _r2x_cache_path_override(): + return _R2X_CACHE_PATH +"#, + cache = cache_path_escaped + ); + + let code_cstr = std::ffi::CString::new(patch_code).map_err(|e| { + BridgeError::Python(format!("Failed to prepare cache override script: {}", e)) + })?; + let filename = std::ffi::CString::new("r2x_cache_patch.py").unwrap(); + let module_name = std::ffi::CString::new("r2x_cache_patch").unwrap(); + let patch_module = PyModule::from_code( + py, + code_cstr.as_c_str(), + filename.as_c_str(), + module_name.as_c_str(), + ) + .map_err(|e| BridgeError::Python(format!("Failed to build cache override: {}", e)))?; + + let override_fn = patch_module + .getattr("_r2x_cache_path_override") + .map_err(|e| { + BridgeError::Python(format!("Failed to obtain cache override function: {}", e)) + })?; + + let file_ops = PyModule::import(py, "r2x_core.utils.file_operations").map_err(|e| { + BridgeError::Python(format!( + "Failed to import r2x_core.utils.file_operations: {}", + e + )) + })?; + + file_ops + .setattr("get_r2x_cache_path", override_fn) + .map_err(|e| BridgeError::Python(format!("Failed to override cache path: {}", e)))?; + + Ok::<(), BridgeError>(()) + })?; + + Ok(()) +} + /// Configure the Python virtual environment before PyO3 initialization pub fn configure_python_venv() -> Result { - let mut config = crate::config_manager::Config::load() + let mut config = Config::load() .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))?; let venv_path = PathBuf::from(config.get_venv_path()); diff --git a/src/python_bridge/mod.rs b/crates/r2x-python/src/lib.rs similarity index 54% rename from src/python_bridge/mod.rs rename to crates/r2x-python/src/lib.rs index 8d8732e..a53ff2c 100644 --- a/src/python_bridge/mod.rs +++ b/crates/r2x-python/src/lib.rs @@ -2,20 +2,18 @@ //! //! This bridge provides a minimal, focused interface for: //! 1. Loading plugin package metadata via entry points -//! 2. Building manifest entries from package schemas -//! 3. Executing plugins with configuration +//! 2. Executing plugins with configuration //! -//! The Package JSON is the single source of truth for all plugin metadata. -//! All plugin information flows through: Python Package → JSON → Manifest +//! Plugin discovery uses AST-based analysis instead of runtime inspection, +//! making it more efficient and reducing Python interpreter overhead. +pub mod errors; mod initialization; -mod manifest_builder; -mod package_loader; -mod plugin_invoker; +pub mod plugin_invoker; mod utils; -pub use initialization::configure_python_venv; -pub use initialization::Bridge; +pub use errors::BridgeError; +pub use initialization::{configure_python_venv, Bridge}; pub use utils::{PYTHON_BIN_DIR, PYTHON_EXE, PYTHON_LIB_DIR, SITE_PACKAGES}; #[cfg(test)] diff --git a/src/python_bridge/manifest_builder.rs b/crates/r2x-python/src/manifest_builder.rs similarity index 98% rename from src/python_bridge/manifest_builder.rs rename to crates/r2x-python/src/manifest_builder.rs index a100da3..86b3a59 100644 --- a/src/python_bridge/manifest_builder.rs +++ b/crates/r2x-python/src/manifest_builder.rs @@ -4,8 +4,10 @@ //! that can be stored and queried by the manifest system. use crate::errors::BridgeError; -use crate::logger; -use crate::plugin_manifest::{CallableMetadata, ConfigMetadata, ParameterMetadata, Plugin}; +use r2x_logger as logger; +use r2x_manifest::{ + CallableMetadata, ConfigMetadata, ParameterMetadata, Plugin, UpgraderMetadata, +}; use std::collections::HashMap; impl super::Bridge { @@ -217,7 +219,7 @@ impl super::Bridge { || plugin_obj.get("version_reader").is_some() || plugin_obj.get("upgrade_steps").is_some() { - Some(crate::plugin_manifest::UpgraderMetadata { + Some(UpgraderMetadata { version_strategy_json: plugin_obj .get("version_strategy") .map(|v| v.to_string()), @@ -243,7 +245,7 @@ impl super::Bridge { config, upgrader, install_type: Some("explicit".to_string()), - installed_by: None, + installed_by: Vec::new(), }; // Use clean plugin name as key (e.g., "reeds-parser") diff --git a/src/python_bridge/package_loader.rs b/crates/r2x-python/src/package_loader.rs similarity index 98% rename from src/python_bridge/package_loader.rs rename to crates/r2x-python/src/package_loader.rs index 3f82bdb..4d2f514 100644 --- a/src/python_bridge/package_loader.rs +++ b/crates/r2x-python/src/package_loader.rs @@ -4,7 +4,8 @@ //! entry points, with both fast (direct import) and slow (importlib.metadata) paths. use crate::errors::BridgeError; -use crate::logger; +use r2x_config::Config; +use r2x_logger as logger; use pyo3::prelude::*; use pyo3::types::PyModule; use std::path::PathBuf; @@ -53,7 +54,7 @@ impl super::Bridge { // Fall back to slow path using importlib.metadata let slow_path_start = std::time::Instant::now(); - let result = Python::with_gil(|py| { + let result = Python::attach(|py| { // Import importlib.metadata let metadata = PyModule::import(py, "importlib.metadata").map_err(|e| { BridgeError::Import("importlib.metadata".to_string(), format!("{}", e)) @@ -132,7 +133,7 @@ impl super::Bridge { logger::debug(&format!("Parsed entry point: {}", ep_info)); - Python::with_gil(|py| { + Python::attach(|py| { // Parse module:function format let parts: Vec<&str> = ep_info.split(':').collect(); if parts.len() != 2 { @@ -200,7 +201,7 @@ impl super::Bridge { use std::fs; // Get the venv path from config - let config = crate::config_manager::Config::load().ok()?; + let config = Config::load().ok()?; let venv_path = PathBuf::from(config.get_venv_path()); logger::debug(&format!( diff --git a/crates/r2x-python/src/plugin_invoker/kwargs.rs b/crates/r2x-python/src/plugin_invoker/kwargs.rs new file mode 100644 index 0000000..77f7de7 --- /dev/null +++ b/crates/r2x-python/src/plugin_invoker/kwargs.rs @@ -0,0 +1,348 @@ +use super::*; +use crate::Bridge; +use pyo3::exceptions::PyFileNotFoundError; +use pyo3::types::{PyDict, PyList, PyModule}; +use r2x_logger as logger; +use r2x_manifest::ConfigMetadata; +use std::path::Path; + +impl Bridge { + pub(super) fn build_kwargs<'py>( + &self, + py: pyo3::Python<'py>, + config_dict: &pyo3::Bound<'py, PyDict>, + stdin_obj: Option<&pyo3::Bound<'py, PyAny>>, + runtime_bindings: Option<&RuntimeBindings>, + ) -> Result, BridgeError> { + let kwargs = PyDict::new(py); + + let runtime = match runtime_bindings { + Some(binding) => binding, + None => { + for (k, v) in config_dict { + kwargs.set_item(k, v)?; + } + if let Some(stdin) = stdin_obj { + kwargs.set_item("stdin", stdin)?; + } + return Ok(kwargs); + } + }; + let obj = &runtime.callable; + + let mut needs_config_class = false; + let mut config_param_name = String::new(); + for (param_name, param_meta) in &obj.parameters { + let annotation = param_meta.annotation.as_deref().unwrap_or(""); + if param_name == "config" || annotation.contains("Config") { + needs_config_class = true; + config_param_name = param_name.clone(); + break; + } + } + + let mut config_instance: Option> = None; + if needs_config_class { + let config_params = if let Ok(Some(existing_config)) = config_dict.get_item("config") { + if let Ok(config_dict_value) = existing_config.cast::() { + config_dict_value.clone() + } else { + PyDict::new(py) + } + } else { + let params = PyDict::new(py); + for (key, value) in config_dict.iter() { + let key_str = key.extract::()?; + if key_str != "data_store" && key_str != "store_path" { + params.set_item(key, value)?; + } + } + params + }; + + let config_obj = + self.instantiate_config_class(py, &config_params, runtime.config.as_ref())?; + kwargs.set_item(&config_param_name, &config_obj)?; + config_instance = Some(config_obj.unbind()); + } + + for (param_name, param_meta) in &obj.parameters { + let annotation = param_meta.annotation.as_deref().unwrap_or(""); + if param_name == "config" || annotation.contains("Config") { + continue; + } + + if param_name == "data_store" || annotation.contains("DataStore") { + logger::step(&format!("Processing data_store parameter: {}", param_name)); + let mut value = config_dict + .get_item("store_path")? + .or_else(|| config_dict.get_item(param_name).ok().flatten()); + if value.is_none() { + value = config_dict.get_item("path").ok().flatten(); + } + + if let Some(value) = value { + let config_binding = config_instance.as_ref().map(|obj| obj.bind(py)); + let store_instance = match config_binding { + Some(ref binding) => self.instantiate_data_store( + py, + &value, + Some(binding), + runtime.config.as_ref(), + )?, + None => { + self.instantiate_data_store(py, &value, None, runtime.config.as_ref())? + } + }; + kwargs.set_item(param_name, store_instance)?; + } + continue; + } + + if let Some(value) = config_dict.get_item(param_name).ok().flatten() { + kwargs.set_item(param_name, value)?; + } else if param_meta.is_required { + logger::warn(&format!( + "Required parameter '{}' missing in config", + param_name + )); + } + } + + if let Some(stdin) = stdin_obj { + if obj.parameters.contains_key("stdin") { + kwargs.set_item("stdin", stdin)?; + } else { + logger::debug( + "Plugin received stdin payload but exposes no 'stdin' parameter; skipping kwargs injection", + ); + } + } + + Ok(kwargs) + } + + pub(super) fn instantiate_config_class<'py>( + &self, + py: pyo3::Python<'py>, + config_params: &pyo3::Bound<'py, PyDict>, + config_metadata: Option<&ConfigMetadata>, + ) -> Result, BridgeError> { + let config_meta = config_metadata + .ok_or_else(|| BridgeError::Python("Plugin config metadata missing".to_string()))?; + + let config_module = PyModule::import(py, &config_meta.module).map_err(|e| { + BridgeError::Python(format!( + "Failed to import config module '{}': {}", + config_meta.module, e + )) + })?; + let config_class = config_module.getattr(&config_meta.name).map_err(|e| { + BridgeError::Python(format!( + "Failed to get config class '{}': {}", + config_meta.name, e + )) + })?; + + config_class.call((), Some(&config_params)).map_err(|e| { + BridgeError::Python(format!( + "Failed to instantiate config class '{}': {}", + config_meta.name, e + )) + }) + } + + pub(super) fn instantiate_data_store<'py>( + &self, + py: pyo3::Python<'py>, + value: &pyo3::Bound<'py, PyAny>, + config_instance: Option<&pyo3::Bound<'py, PyAny>>, + config_metadata: Option<&ConfigMetadata>, + ) -> Result, BridgeError> { + let path = if let Ok(store_dict) = value.cast::() { + let path = store_dict + .get_item("path")? + .ok_or_else(|| BridgeError::Python("data_store path missing".to_string()))? + .extract::()?; + path + } else if let Ok(path_str) = value.extract::() { + path_str + } else { + return Err(BridgeError::Python( + "Invalid data_store format. Provide dict or store path".to_string(), + )); + }; + + let data_store_module = PyModule::import(py, "r2x_core.store")?; + let data_store_class = data_store_module.getattr("DataStore")?; + + if let Some(config) = config_instance { + let store_path = path.clone(); + let from_config = data_store_class + .getattr("from_plugin_config") + .map_err(|e| { + BridgeError::Python(format!("DataStore missing from_plugin_config: {}", e)) + })?; + match from_config.call1((config, path)) { + Ok(store) => Ok(store), + Err(err) => { + logger::debug( + "DataStore.from_plugin_config failed; attempting targeted diagnostics", + ); + logger::debug(&format!( + "Config metadata present: {}", + config_metadata.is_some() + )); + if let Some(class_obj) = resolve_config_class(py, Some(config), config_metadata) + { + if let Some(missing) = + detect_missing_data_file_from_mapping(&class_obj, &store_path) + { + return Err(BridgeError::Python(format!( + "Missing required ReEDS data file: {}. \ +Verify the data folder contains all expected outputs (did you unpack the full `inputs_case` directory?).", + missing + ))); + } + } else if let Some(missing) = + detect_missing_data_file_from_metadata(py, config_metadata, &store_path) + { + return Err(BridgeError::Python(format!( + "Missing required ReEDS data file: {}. \ +Verify the data folder contains all expected outputs (did you unpack the full `inputs_case` directory?).", + missing + ))); + } + Err(transform_data_store_error(py, err)) + } + } + } else { + let store_path = path.clone(); + match data_store_class.call1((path,)) { + Ok(store) => Ok(store), + Err(err) => { + logger::debug(&format!( + "DataStore(path) failed; config metadata present: {}", + config_metadata.is_some() + )); + if let Some(missing) = + detect_missing_data_file_from_metadata(py, config_metadata, &store_path) + { + Err(BridgeError::Python(format!( + "Missing required ReEDS data file: {}. \ +Verify the data folder contains all expected outputs (did you unpack the full `inputs_case` directory?).", + missing + ))) + } else { + Err(transform_data_store_error(py, err)) + } + } + } + } + } +} + +fn transform_data_store_error(py: pyo3::Python<'_>, err: pyo3::PyErr) -> BridgeError { + if let Some(missing) = extract_missing_data_file(py, &err) { + BridgeError::Python(format!( + "Missing required ReEDS data file: {}. \ +Verify the data folder contains all expected outputs (did you unpack the full `inputs_case` directory?).", + missing + )) + } else { + BridgeError::Python(format!("Failed to instantiate DataStore: {}", err)) + } +} + +fn extract_missing_data_file(py: pyo3::Python<'_>, err: &pyo3::PyErr) -> Option { + let mut current = err.value(py).getattr("__context__").ok(); + let mut depth = 0; + loop { + let Some(ctx) = current else { break }; + if ctx.is_none() { + break; + } + if let Ok(repr) = ctx.str() { + logger::debug(&format!( + "Python exception context[{}]: {}", + depth, + repr.to_string() + )); + } + if ctx.is_instance_of::() { + if let Ok(text) = ctx.str() { + return Some(text.to_string()); + } + } + current = ctx.getattr("__context__").ok(); + depth += 1; + } + None +} + +fn detect_missing_data_file_from_mapping( + class_obj: &pyo3::Bound<'_, PyAny>, + folder_path: &str, +) -> Option { + logger::debug(&format!( + "Validating ReEDS data files under {}", + folder_path + )); + let loader = class_obj.getattr("load_file_mapping").ok()?; + let records = loader.call0().ok()?; + let records = records.cast::().ok()?; + let base = Path::new(folder_path); + + for record in records { + let record = record.cast::().ok()?; + let optional = record + .get_item("optional") + .ok() + .flatten() + .and_then(|val| val.extract::().ok()) + .unwrap_or(false); + if optional { + continue; + } + + let Some(fpath_obj) = record.get_item("fpath").ok().flatten() else { + continue; + }; + let Ok(rel_path) = fpath_obj.extract::() else { + continue; + }; + let full_path = base.join(rel_path); + if !full_path.exists() { + logger::debug(&format!( + "Detected missing data file during ReEDS run: {}", + full_path.display() + )); + return Some(full_path.to_string_lossy().to_string()); + } + } + + None +} + +fn detect_missing_data_file_from_metadata( + py: pyo3::Python<'_>, + metadata: Option<&ConfigMetadata>, + folder_path: &str, +) -> Option { + let class_obj = resolve_config_class(py, None, metadata)?; + detect_missing_data_file_from_mapping(&class_obj, folder_path) +} + +fn resolve_config_class<'py>( + py: pyo3::Python<'py>, + config_instance: Option<&pyo3::Bound<'py, PyAny>>, + metadata: Option<&ConfigMetadata>, +) -> Option> { + if let Some(instance) = config_instance { + return instance.getattr("__class__").ok(); + } + + let meta = metadata?; + let module = PyModule::import(py, &meta.module).ok()?; + module.getattr(&meta.name).ok() +} diff --git a/crates/r2x-python/src/plugin_invoker/mod.rs b/crates/r2x-python/src/plugin_invoker/mod.rs new file mode 100644 index 0000000..46629ed --- /dev/null +++ b/crates/r2x-python/src/plugin_invoker/mod.rs @@ -0,0 +1,68 @@ +//! Plugin invocation and execution + +use crate::errors::BridgeError; +use pyo3::prelude::*; +use r2x_logger as logger; +use r2x_manifest::{ + runtime::{build_runtime_bindings, RuntimeBindings}, + DiscoveryPlugin, +}; +use std::time::Duration; + +mod kwargs; +mod regular; +mod upgrader; + +/// Timings for a plugin invocation phase +pub struct PluginInvocationTimings { + pub python_invocation: Duration, + pub serialization: Duration, +} + +/// Result of running a plugin through the Python bridge +pub struct PluginInvocationResult { + /// JSON text emitted by the plugin (may be `"null"`) + pub output: String, + /// Optional per-phase timings for diagnostics + pub timings: Option, +} + +impl super::Bridge { + pub fn invoke_plugin( + &self, + target: &str, + config_json: &str, + stdin_json: Option<&str>, + plugin_metadata: Option<&DiscoveryPlugin>, + ) -> Result { + let runtime_bindings = match plugin_metadata { + Some(meta) => Some( + build_runtime_bindings(meta) + .map_err(|e| BridgeError::Python(format!("Invalid plugin metadata: {}", e)))?, + ), + None => None, + }; + + if let Some(plugin) = plugin_metadata { + if plugin.plugin_type == "UpgraderPlugin" { + logger::debug("Routing to upgrader plugin handler"); + return self.invoke_upgrader_plugin( + target, + config_json, + runtime_bindings.as_ref(), + plugin_metadata, + ); + } + } + + self.invoke_plugin_regular(target, config_json, stdin_json, runtime_bindings.as_ref()) + } +} + +#[cfg(test)] +mod tests { + #[test] + fn test_plugin_invocation_placeholder() { + assert!(true); + } +} diff --git a/crates/r2x-python/src/plugin_invoker/regular.rs b/crates/r2x-python/src/plugin_invoker/regular.rs new file mode 100644 index 0000000..c7661ef --- /dev/null +++ b/crates/r2x-python/src/plugin_invoker/regular.rs @@ -0,0 +1,283 @@ +use super::{ + logger, BridgeError, PluginInvocationResult, PluginInvocationTimings, RuntimeBindings, +}; +use crate::Bridge; +use pyo3::types::{PyAny, PyAnyMethods, PyDict, PyModule}; +use pyo3::PyResult; +use std::time::{Duration, Instant}; + +impl Bridge { + pub(super) fn invoke_plugin_regular( + &self, + target: &str, + config_json: &str, + stdin_json: Option<&str>, + runtime_bindings: Option<&RuntimeBindings>, + ) -> Result { + pyo3::Python::attach(|py| { + logger::debug(&format!("Parsing target: {}", target)); + let parts: Vec<&str> = target.split(':').collect(); + if parts.len() != 2 { + return Err(BridgeError::InvalidEntryPoint(target.to_string())); + } + let module_path = parts[0]; + let callable_path = parts[1]; + + logger::debug(&format!("Importing module: {}", module_path)); + let module = PyModule::import(py, module_path) + .map_err(|e| BridgeError::Import(module_path.to_string(), format!("{}", e)))?; + let json_module = PyModule::import(py, "json") + .map_err(|e| BridgeError::Import("json".to_string(), format!("{}", e)))?; + let loads = json_module.getattr("loads")?; + + logger::debug("Parsing config JSON"); + let config_dict = loads + .call1((config_json,))? + .cast::() + .map_err(|e| BridgeError::Python(format!("Config must be a JSON object: {}", e)))? + .clone(); + + let stdin_obj = if let Some(stdin) = stdin_json { + logger::debug("Parsing stdin JSON"); + Some(loads.call1((stdin,))?) + } else { + None + }; + + logger::debug("Building kwargs for plugin invocation"); + let kwargs = + self.build_kwargs(py, &config_dict, stdin_obj.as_ref(), runtime_bindings)?; + + logger::debug("Starting plugin invocation"); + let call_start = Instant::now(); + let result_py = if callable_path.contains('.') { + Self::invoke_class_callable(&module, callable_path, stdin_obj.as_ref(), &kwargs)? + } else { + Self::invoke_function_callable( + py, + &module, + callable_path, + stdin_obj.as_ref(), + &kwargs, + &json_module, + )? + }; + let call_elapsed = call_start.elapsed(); + logger::debug(&format!( + "Python invocation for '{}' took {}", + callable_path, + format_duration(call_elapsed) + )); + logger::debug("Plugin execution completed"); + logger::debug("Serializing result to JSON"); + + let (json_str, ser_elapsed) = if result_py.hasattr("to_json")? { + let ser_start = Instant::now(); + let to_json_result = result_py.call_method0("to_json")?; + let json_str = if let Ok(json_bytes) = to_json_result.extract::>() { + String::from_utf8(json_bytes).map_err(|e| { + BridgeError::Python(format!("Invalid UTF-8 in JSON output: {}", e)) + })? + } else { + let dumps = json_module.getattr("dumps")?; + dumps.call1((result_py,))?.extract::()? + }; + let ser_elapsed = ser_start.elapsed(); + logger::debug(&format!( + "Serialization for '{}' took {}", + callable_path, + format_duration(ser_elapsed) + )); + (json_str, ser_elapsed) + } else { + let ser_start = Instant::now(); + let dumps = json_module.getattr("dumps")?; + let json_str = dumps.call1((result_py,))?.extract::()?; + let ser_elapsed = ser_start.elapsed(); + logger::debug(&format!( + "Serialization for '{}' took {}", + callable_path, + format_duration(ser_elapsed) + )); + (json_str, ser_elapsed) + }; + + Ok(PluginInvocationResult { + output: json_str, + timings: Some(PluginInvocationTimings { + python_invocation: call_elapsed, + serialization: ser_elapsed, + }), + }) + }) + } + + fn invoke_class_callable<'py>( + module: &pyo3::Bound<'py, PyModule>, + callable_path: &str, + stdin_obj: Option<&pyo3::Bound<'py, PyAny>>, + kwargs: &pyo3::Bound<'py, PyDict>, + ) -> Result, BridgeError> { + let parts: Vec<&str> = callable_path.split('.').collect(); + if parts.len() != 2 { + return Err(BridgeError::InvalidEntryPoint(callable_path.to_string())); + } + let (class_name, method_name) = (parts[0], parts[1]); + + let class = module.getattr(class_name).map_err(|e| { + BridgeError::Python(format_python_error( + module.py(), + e, + &format!("Failed to get class '{}'", class_name), + )) + })?; + + let instance = class.call((), Some(kwargs)).map_err(|err| { + let raw_msg = err.to_string(); + let mut formatted = format_python_error( + class.py(), + err, + &format!("Failed to instantiate '{}'", class_name), + ); + if raw_msg.contains("missing") && raw_msg.contains("required positional argument") { + formatted.push_str("\n\nHint: This may happen if the plugin metadata cache is stale. Try running:\n r2x sync"); + } + BridgeError::Python(formatted) + })?; + + let method = instance.getattr(method_name).map_err(|e| { + BridgeError::Python(format_python_error( + instance.py(), + e, + &format!("Failed to get method '{}.{}'", class_name, method_name), + )) + })?; + + let accepts_stdin = if stdin_obj.is_some() { + match method_accepts_stdin(&method) { + Ok(result) => result, + Err(err) => { + logger::debug(&format!( + "Failed to inspect method '{}.{}' signature for stdin support: {}", + class_name, method_name, err + )); + false + } + } + } else { + false + }; + + if accepts_stdin { + let stdin = stdin_obj.expect("checked Some above"); + method.call1((stdin,)).map_err(|e| { + BridgeError::Python(format_python_error( + method.py(), + e, + &format!("Method '{}.{}' failed", class_name, method_name), + )) + }) + } else { + if stdin_obj.is_some() { + logger::debug(&format!( + "Method '{}.{}' does not declare 'system'/'stdin'; skipping stdin payload", + class_name, method_name + )); + } + method.call0().map_err(|e| { + BridgeError::Python(format_python_error( + method.py(), + e, + &format!("Method '{}.{}' failed", class_name, method_name), + )) + }) + } + } + + fn invoke_function_callable<'py>( + py: pyo3::Python<'py>, + module: &pyo3::Bound<'py, PyModule>, + callable_path: &str, + stdin_obj: Option<&pyo3::Bound<'py, PyAny>>, + kwargs: &pyo3::Bound<'py, PyDict>, + json_module: &pyo3::Bound<'py, PyModule>, + ) -> Result, BridgeError> { + logger::debug(&format!("Function pattern: {}", callable_path)); + let func = module.getattr(callable_path).map_err(|e| { + BridgeError::Python(format_python_error( + module.py(), + e, + &format!("Failed to get function '{}'", callable_path), + )) + })?; + + logger::step(&format!("Function kwargs before system: {:?}", kwargs)); + if let Some(stdin) = stdin_obj { + logger::step("Function has stdin - deserializing to System object"); + let dumps = json_module.getattr("dumps")?; + let json_str = dumps.call1((stdin,))?.extract::()?; + let json_bytes = json_str.as_bytes(); + + let system_module = PyModule::import(py, "r2x_core.system")?; + let system_class = system_module.getattr("System")?; + let from_json = system_class.getattr("from_json")?; + let system_obj = from_json.call1((json_bytes,))?; + kwargs.set_item("system", system_obj)?; + } + + logger::step(&format!("Final function kwargs: {:?}", kwargs)); + func.call((), Some(kwargs)).map_err(|e| { + BridgeError::Python(format_python_error( + func.py(), + e, + &format!("Function '{}' failed", callable_path), + )) + }) + } +} + +fn format_duration(duration: Duration) -> String { + let total_ms = duration.as_millis(); + if total_ms < 1000 { + format!("{}ms", total_ms) + } else { + format!("{:.2}s", duration.as_secs_f64()) + } +} + +fn format_python_error(py: pyo3::Python<'_>, err: pyo3::PyErr, context: &str) -> String { + if let Some(traceback_text) = render_traceback(py, &err) { + format!("{}:\n{}", context, traceback_text) + } else { + format!("{}: {}", context, err) + } +} + +fn render_traceback(py: pyo3::Python<'_>, err: &pyo3::PyErr) -> Option { + let traceback = err.traceback(py)?; + let traceback_module = PyModule::import(py, "traceback").ok()?; + let formatter = traceback_module.getattr("format_exception").ok()?; + let formatted = formatter + .call1((err.get_type(py), err.value(py), traceback)) + .ok()?; + let lines: Vec = formatted.extract().ok()?; + Some(lines.join("")) +} + +fn method_accepts_stdin(method: &pyo3::Bound<'_, PyAny>) -> PyResult { + let code = method.getattr("__code__")?; + let argcount: usize = code.getattr("co_argcount")?.extract()?; + if argcount <= 1 { + return Ok(false); + } + + let varnames: Vec = code.getattr("co_varnames")?.extract()?; + let usable = argcount.min(varnames.len()); + if usable <= 1 { + return Ok(false); + } + + Ok(varnames[1..usable] + .iter() + .any(|name| name == "system" || name == "stdin")) +} diff --git a/crates/r2x-python/src/plugin_invoker/upgrader.rs b/crates/r2x-python/src/plugin_invoker/upgrader.rs new file mode 100644 index 0000000..ba71aae --- /dev/null +++ b/crates/r2x-python/src/plugin_invoker/upgrader.rs @@ -0,0 +1,349 @@ +use super::{logger, BridgeError, PluginInvocationResult, RuntimeBindings}; +use crate::Bridge; +use pyo3::types::{PyAny, PyAnyMethods, PyDict, PyDictMethods, PyModule, PyString}; +use r2x_manifest::DiscoveryPlugin; +use std::path::{Path, PathBuf}; + +impl Bridge { + pub(super) fn invoke_upgrader_plugin( + &self, + target: &str, + config_json: &str, + runtime_bindings: Option<&RuntimeBindings>, + plugin_metadata: Option<&DiscoveryPlugin>, + ) -> Result { + pyo3::Python::attach(|py| { + logger::debug(&format!("Invoking upgrader plugin: {}", target)); + let parts: Vec<&str> = target.split(':').collect(); + if parts.len() != 2 { + return Err(BridgeError::InvalidEntryPoint(target.to_string())); + } + let module_path = parts[0]; + let callable_path = parts[1]; + + let module = PyModule::import(py, module_path) + .map_err(|e| BridgeError::Import(module_path.to_string(), format!("{}", e)))?; + let json_module = PyModule::import(py, "json") + .map_err(|e| BridgeError::Import("json".to_string(), format!("{}", e)))?; + let loads = json_module.getattr("loads")?; + let config_dict = loads + .call1((config_json,))? + .cast::() + .map_err(|e| BridgeError::Python(format!("Config must be a JSON object: {}", e)))? + .clone(); + + let kwargs = self.build_kwargs(py, &config_dict, None, runtime_bindings)?; + let upgrader_class = module.getattr(callable_path).map_err(|e| { + BridgeError::Python(format!( + "Failed to get upgrader class '{}': {}", + callable_path, e + )) + })?; + + if let Some(plugin) = plugin_metadata { + if let Some(strategy) = find_arg_value(plugin, "version_strategy") { + kwargs.set_item("version_strategy", strategy)?; + } + if let Some(reader) = find_arg_value(plugin, "version_reader") { + kwargs.set_item("version_reader", reader)?; + } + if let Some(steps) = find_arg_value(plugin, "upgrade_steps") { + kwargs.set_item("upgrade_steps", steps)?; + } + } + + let instance = upgrader_class.call((), Some(&kwargs)).map_err(|e| { + BridgeError::Python(format!( + "Failed to instantiate upgrader '{}': {}", + callable_path, e + )) + })?; + + if instance.hasattr("run")? { + let output = instance + .call_method0("run")? + .extract::() + .map_err(|e| { + BridgeError::Python(format!( + "Failed to run upgrader '{}': {}", + callable_path, e + )) + })?; + Ok(PluginInvocationResult { + output, + timings: None, + }) + } else { + logger::debug("Upgrader missing run() method, invoking registered steps directly"); + let output = Self::invoke_registered_steps(&instance)?; + Ok(PluginInvocationResult { + output, + timings: None, + }) + } + }) + } +} + +fn find_arg_value<'a>(plugin: &'a DiscoveryPlugin, name: &str) -> Option<&'a str> { + plugin + .constructor_args + .iter() + .find(|arg| arg.name == name) + .map(|arg| arg.value.as_str()) +} + +impl Bridge { + fn invoke_registered_steps<'py>( + instance: &pyo3::Bound<'py, pyo3::PyAny>, + ) -> Result { + let steps = instance + .getattr("steps") + .map_err(|e| BridgeError::Python(format!("Failed to access upgrader steps: {}", e)))?; + + let path_obj = instance.getattr("path").map_err(|e| { + BridgeError::Python(format!("Upgrader missing 'path' attribute: {}", e)) + })?; + let path_str = path_obj + .str() + .map_err(|e| BridgeError::Python(format!("Invalid upgrader path: {}", e)))? + .to_string(); + let path_buf = PathBuf::from(path_str); + let path_handle = path_obj.clone().unbind(); + + let py = instance.py(); + + let upgrader_utils = PyModule::import(py, "r2x_core.upgrader_utils").map_err(|e| { + BridgeError::Import("r2x_core.upgrader_utils".to_string(), format!("{}", e)) + })?; + let run_upgrade_step = upgrader_utils.getattr("run_upgrade_step").map_err(|e| { + BridgeError::Python(format!( + "Failed to import r2x_core.upgrader_utils.run_upgrade_step: {}", + e + )) + })?; + + let json_module = PyModule::import(py, "json") + .map_err(|e| BridgeError::Import("json".to_string(), format!("{}", e)))?; + let loads = json_module.getattr("loads")?; + let dumps = json_module.getattr("dumps")?; + + let mut system_data: Option> = None; + let mut system_json_path: Option = None; + + for step in steps.try_iter()? { + let step_obj = step.map_err(|e| BridgeError::Python(format!("{}", e)))?; + let upgrade_type_obj = step_obj.getattr("upgrade_type").map_err(|e| { + BridgeError::Python(format!("Invalid upgrade step (missing type): {}", e)) + })?; + + let upgrade_value = upgrade_type_obj + .getattr("value") + .or_else(|_| Ok(upgrade_type_obj.clone())) + .and_then(|obj| obj.str().map(|s| s.to_string())) + .map_err(|e| BridgeError::Python(format!("Invalid upgrade type: {}", e)))?; + logger::debug(&format!( + "Upgrade step type for current step: {}", + upgrade_value + )); + let upgrade_is_system = + upgrade_value.eq_ignore_ascii_case("SYSTEM") || upgrade_value.ends_with(".SYSTEM"); + let upgrade_is_file = + upgrade_value.eq_ignore_ascii_case("FILE") || upgrade_value.ends_with(".FILE"); + + logger::debug(&format!( + "Executing upgrade step: {}", + step_obj + .getattr("name") + .and_then(|n| n.extract::()) + .unwrap_or_else(|_| "".to_string()) + )); + + let data_arg = if upgrade_is_system { + if system_data.is_none() { + let resolved = + resolve_system_json_path(&path_buf).map_err(BridgeError::Python)?; + let data = load_system_data(py, &loads, &resolved)?; + system_data = Some(data); + system_json_path = Some(resolved); + } + system_data + .as_ref() + .expect("system_data populated") + .clone_ref(py) + } else { + path_handle.clone_ref(py) + }; + + let kwargs = PyDict::new(py); + kwargs + .set_item("upgrader_context", instance) + .map_err(|e| BridgeError::Python(format!("Failed to set context: {}", e)))?; + + let result = run_upgrade_step + .call((step_obj.clone(), data_arg), Some(&kwargs)) + .map_err(|e| { + BridgeError::Python(format!("Upgrade step execution failed: {}", e)) + })?; + + let is_err = result + .getattr("is_err")? + .call0() + .and_then(|v| v.is_truthy()) + .map_err(|e| BridgeError::Python(format!("Failed to inspect result: {}", e)))?; + + if is_err { + let err_obj = result + .getattr("unwrap_err")? + .call0() + .map_err(|e| BridgeError::Python(format!("Failed to fetch error: {}", e)))?; + let err_text = err_obj + .str() + .map(|s| s.to_string()) + .unwrap_or_else(|_| "".to_string()); + return Err(BridgeError::Python(format!( + "Upgrade step execution failed: {}", + err_text + ))); + } + + if upgrade_is_system { + let value_obj = result.getattr("unwrap")?.call0().map_err(|e| { + BridgeError::Python(format!("Failed to unwrap upgrade result: {}", e)) + })?; + if !value_obj.is_none() { + system_data = Some(value_obj.into()); + } + } else if !upgrade_is_file { + logger::warn(&format!( + "Unknown upgrade type '{}' for step {}; defaulting to pass-through", + upgrade_value, + step_obj + .getattr("name") + .and_then(|n| n.extract::()) + .unwrap_or_else(|_| "".into()) + )); + } + } + + let final_json_path = if let Some(json_path) = system_json_path { + if let Some(ref data) = system_data { + write_system_data(py, &dumps, data, &json_path)?; + } + json_path + } else { + resolve_system_json_path(&path_buf).unwrap_or(path_buf.clone()) + }; + + if let Some(data) = system_data { + let json_str: String = dumps + .call1((data.bind(py),)) + .map_err(|e| { + BridgeError::Python(format!("Failed to serialize upgraded system: {}", e)) + })? + .extract() + .map_err(|e| { + BridgeError::Python(format!("Failed to extract upgraded system JSON: {}", e)) + })?; + Ok(json_str) + } else { + let contents = std::fs::read_to_string(&final_json_path).map_err(|e| { + BridgeError::Python(format!( + "Failed to read upgraded system JSON {}: {}", + final_json_path.display(), + e + )) + })?; + Ok(contents) + } + } +} + +fn resolve_system_json_path(path: &Path) -> Result { + if path.is_file() { + return Ok(path.to_path_buf()); + } + + if path.is_dir() { + let candidate = path.join("system.json"); + if candidate.exists() { + return Ok(candidate); + } + + if let Ok(mut entries) = std::fs::read_dir(path) { + while let Some(Ok(entry)) = entries.next() { + let entry_path = entry.path(); + if entry_path + .extension() + .map(|ext| ext == "json") + .unwrap_or(false) + { + return Ok(entry_path); + } + } + } + } + + Err(format!( + "Unable to locate JSON file for upgrader at {}", + path.display() + )) +} + +fn load_system_data<'py>( + py: pyo3::Python<'py>, + loads: &pyo3::Bound<'py, pyo3::PyAny>, + json_path: &Path, +) -> Result, BridgeError> { + let content = std::fs::read_to_string(json_path).map_err(|e| { + BridgeError::Python(format!( + "Failed to read system JSON {}: {}", + json_path.display(), + e + )) + })?; + let py_str = PyString::new(py, &content); + let data = loads.call1((py_str,)).map_err(|e| { + BridgeError::Python(format!( + "Failed to parse system JSON {}: {}", + json_path.display(), + e + )) + })?; + Ok(data.into()) +} + +fn write_system_data<'py>( + py: pyo3::Python<'py>, + dumps: &pyo3::Bound<'py, pyo3::PyAny>, + data: &pyo3::Py, + json_path: &Path, +) -> Result<(), BridgeError> { + let kwargs = PyDict::new(py); + kwargs.set_item("indent", 2)?; + kwargs.set_item("ensure_ascii", false)?; + let json_str: String = dumps + .call((data.bind(py),), Some(&kwargs)) + .map_err(|e| { + BridgeError::Python(format!( + "Failed to serialize upgraded system JSON {}: {}", + json_path.display(), + e + )) + })? + .extract() + .map_err(|e| { + BridgeError::Python(format!( + "Failed to convert upgraded system JSON {}: {}", + json_path.display(), + e + )) + })?; + std::fs::write(json_path, json_str).map_err(|e| { + BridgeError::Python(format!( + "Failed to write upgraded system JSON {}: {}", + json_path.display(), + e + )) + }) +} diff --git a/src/python_bridge/utils.rs b/crates/r2x-python/src/utils.rs similarity index 100% rename from src/python_bridge/utils.rs rename to crates/r2x-python/src/utils.rs diff --git a/rule.yaml b/rule.yaml new file mode 100644 index 0000000..e3b719c --- /dev/null +++ b/rule.yaml @@ -0,0 +1,6 @@ +id: extract_typed_param +language: python +rule: + any: + - kind: typed_parameter + - kind: typed_default_parameter diff --git a/src/commands/read.rs b/src/commands/read.rs deleted file mode 100644 index 0936a73..0000000 --- a/src/commands/read.rs +++ /dev/null @@ -1,147 +0,0 @@ -use crate::logger; -use crate::GlobalOpts; -use clap::Parser; -use std::fs; -use std::io::Read; -use std::path::PathBuf; -use std::process::{Command, Stdio}; - -#[derive(Parser, Debug)] -pub struct ReadCommand { - /// Path to JSON file to read. If not provided, reads from stdin - pub file: Option, -} - -pub fn handle_read(cmd: ReadCommand, _opts: GlobalOpts) -> Result<(), Box> { - logger::debug("Starting read command"); - - // Load configuration - let config = crate::config_manager::Config::load()?; - let venv_path = config.get_venv_path(); - - // Get Python executable path - #[cfg(unix)] - let python_exe = format!("{}/bin/python", venv_path); - #[cfg(windows)] - let python_exe = format!("{}\\Scripts\\python.exe", venv_path); - - logger::debug(&format!("Python executable: {}", python_exe)); - - // Load JSON input - let json_file_path = if let Some(file_path) = cmd.file { - logger::debug(&format!("Reading JSON from file: {}", file_path.display())); - file_path - } else { - logger::debug("Reading JSON from stdin"); - let mut json_data = String::new(); - std::io::stdin() - .read_to_string(&mut json_data) - .map_err(|e| format!("Failed to read from stdin: {}", e))?; - - let temp_json = std::env::temp_dir().join("r2x_input.json"); - fs::write(&temp_json, &json_data) - .map_err(|e| format!("Failed to write temporary JSON file: {}", e))?; - - logger::debug(&format!("Saved stdin to temporary file: {}", temp_json.display())); - temp_json - }; - - // Generate Python initialization code - let file_path_str = json_file_path - .to_str() - .ok_or("Invalid file path")? - .replace('\\', "\\\\"); - - let python_code = format!( - r#"import json, os -from r2x_core.system import System -try: - with open(r'{}', 'r') as f: - data = json.load(f) - cwd = os.getcwd() - system = System.from_dict(data, cwd) - print('✔ System deserialized successfully') - print('System object available as system') -except Exception as e: - print(f'Error loading system: {{e}}') - import traceback - traceback.print_exc() - system = None -"#, - file_path_str - ); - - logger::debug("Generated Python initialization code"); - - logger::success("Launching interactive IPython session..."); - - // Open /dev/tty to get a real terminal for interactive input - // (stdin was already consumed by reading JSON, so we need a fresh connection) - #[cfg(unix)] - let stdin_source = fs::File::open("/dev/tty") - .map(Stdio::from) - .unwrap_or_else(|_| { - logger::debug("Could not open /dev/tty, using inherited stdin"); - Stdio::inherit() - }); - - #[cfg(windows)] - let stdin_source = fs::OpenOptions::new() - .read(true) - .write(true) - .open("CON") - .map(Stdio::from) - .unwrap_or_else(|_| { - logger::debug("Could not open CON, using inherited stdin"); - Stdio::inherit() - }); - - // Spawn IPython with flags for interactive mode - let mut child = Command::new(&python_exe) - .arg("-m") - .arg("IPython") - .arg("-i") // Force interactive mode - .arg("--simple-prompt") // Simplified prompt - .arg("--quick") // Skip startup scripts - .arg("-c") - .arg(&python_code) - .stdin(stdin_source) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .spawn() - .map_err(|e| format!("Failed to spawn IPython process: {}", e))?; - - logger::debug("IPython process spawned, waiting for completion"); - - // Wait for IPython to finish - let status = child.wait() - .map_err(|e| format!("Failed to wait for IPython process: {}", e))?; - - if !status.success() { - let exit_code = status.code().unwrap_or(-1); - logger::debug(&format!("IPython exited with code: {}", exit_code)); - return Err(format!("IPython exited with code {}", exit_code).into()); - } - - logger::debug("IPython session completed successfully"); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_read_command_creation() { - let cmd = ReadCommand { file: None }; - assert!(cmd.file.is_none()); - } - - #[test] - fn test_read_command_with_file() { - let cmd = ReadCommand { - file: Some(PathBuf::from("test.json")), - }; - assert!(cmd.file.is_some()); - } -} diff --git a/src/commands/run.rs b/src/commands/run.rs deleted file mode 100644 index ee05cc7..0000000 --- a/src/commands/run.rs +++ /dev/null @@ -1,656 +0,0 @@ -use crate::errors::{BridgeError, ManifestError, PipelineError}; -use crate::help::{show_plugin_help, show_run_help}; -use crate::logger; -use crate::package_verification; -use crate::pipeline_config::PipelineConfig; -use crate::plugin_manifest::PluginManifest; -use crate::python_bridge::Bridge; -use crate::GlobalOpts; -use clap::Parser; -use colored::Colorize; -use std::time::Instant; - -#[derive(Debug)] -pub enum RunError { - Manifest(ManifestError), - Bridge(BridgeError), - Pipeline(PipelineError), - Config(String), - PluginNotFound(String), - InvalidArgs(String), - Verification(String), -} - -impl std::fmt::Display for RunError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - RunError::Manifest(e) => write!(f, "Manifest error: {}", e), - RunError::Bridge(e) => write!(f, "Python bridge error: {}", e), - RunError::Pipeline(e) => write!(f, "Pipeline error: {}", e), - RunError::Config(msg) => write!(f, "Configuration error: {}", msg), - RunError::PluginNotFound(name) => write!(f, "Plugin '{}' not found in manifest", name), - RunError::InvalidArgs(msg) => write!(f, "Invalid arguments: {}", msg), - RunError::Verification(msg) => write!(f, "Package verification error: {}", msg), - } - } -} - -impl std::error::Error for RunError {} - -impl From for RunError { - fn from(e: ManifestError) -> Self { - RunError::Manifest(e) - } -} - -impl From for RunError { - fn from(e: BridgeError) -> Self { - RunError::Bridge(e) - } -} - -impl From for RunError { - fn from(e: PipelineError) -> Self { - RunError::Pipeline(e) - } -} - -/// Run pipelines or plugins -#[derive(Parser, Debug)] -pub struct RunCommand { - #[command(subcommand)] - pub command: Option, - - /// Path to pipeline YAML file (used when no subcommand) - #[arg(value_name = "YAML_PATH")] - pub yaml_path: Option, - - /// Pipeline name to execute (used when no subcommand) - #[arg(value_name = "NAME")] - pub pipeline_name: Option, - - /// List available pipelines (used when no subcommand) - #[arg(long)] - pub list: bool, - - /// Print resolved pipeline configuration (used when no subcommand) - #[arg(long)] - pub print: bool, - - /// Show pipeline flow without executing (display which plugins produce/consume stdout) - #[arg(long)] - pub dry_run: bool, - - /// Output file for final pipeline stdout (used when no subcommand) - #[arg(short = 'o', long, value_name = "FILE")] - pub output: Option, -} - -#[derive(Parser, Debug)] -pub enum RunSubcommand { - /// Run a plugin directly - Plugin(PluginCommand), -} - -#[derive(Parser, Debug)] -pub struct PluginCommand { - /// Plugin name to run (optional - if not provided, lists available plugins) - pub plugin_name: Option, - - /// Show help for the plugin - #[arg(long)] - pub show_help: bool, - - /// Plugin arguments as key=value pairs - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] - pub args: Vec, -} - -pub fn handle_run(cmd: RunCommand, _opts: GlobalOpts) -> Result<(), RunError> { - match cmd.command { - Some(RunSubcommand::Plugin(plugin_cmd)) => handle_plugin_command(plugin_cmd), - None => { - // No subcommand - run pipeline mode - if let Some(yaml_path) = cmd.yaml_path { - handle_pipeline_mode( - yaml_path, - cmd.pipeline_name, - cmd.list, - cmd.print, - cmd.dry_run, - cmd.output, - ) - } else { - // No subcommand and no yaml_path - show help - show_run_help().map_err(|e| RunError::Config(format!("Help error: {}", e)))?; - Ok(()) - } - } - } -} - -fn handle_pipeline_mode( - yaml_path: String, - pipeline_name: Option, - list: bool, - print: bool, - dry_run: bool, - output: Option, -) -> Result<(), RunError> { - let config = PipelineConfig::load(&yaml_path)?; - - if list { - list_pipelines(&config); - } else if print { - if let Some(name) = pipeline_name { - print_pipeline_config(&config, &name)?; - } else { - return Err(RunError::InvalidArgs( - "Pipeline name required with --print".to_string(), - )); - } - } else if let Some(name) = pipeline_name { - if dry_run { - show_pipeline_flow(&config, &name)?; - } else { - run_pipeline(&config, &name, output.as_deref())?; - } - } else { - return Err(RunError::InvalidArgs( - "Pipeline name required for execution".to_string(), - )); - } - - Ok(()) -} - -fn handle_plugin_command(cmd: PluginCommand) -> Result<(), RunError> { - match cmd.plugin_name { - Some(plugin_name) => { - if cmd.show_help { - show_plugin_help(&plugin_name) - .map_err(|e| RunError::Config(format!("Help error: {}", e)))?; - } else { - run_plugin(&plugin_name, &cmd.args)?; - } - } - None => { - // No plugin name provided - list available plugins - list_available_plugins()?; - } - } - Ok(()) -} - -fn list_available_plugins() -> Result<(), RunError> { - let manifest = PluginManifest::load()?; - - if manifest.is_empty() { - println!("No plugins installed."); - println!(); - println!("To install a plugin, run:"); - println!(" r2x install "); - return Ok(()); - } - - println!("Available plugins:"); - println!(); - - // Group plugins by package name and type - use std::collections::BTreeMap; - let plugins = manifest.list_plugins(); - let mut packages: BTreeMap>> = BTreeMap::new(); - - for (name, plugin) in &plugins { - let package_name = plugin - .package_name - .clone() - .unwrap_or_else(|| "unknown".to_string()); - - let plugin_type = if let Some(obj) = &plugin.obj { - obj.callable_type.clone() - } else { - plugin - .plugin_type - .clone() - .unwrap_or_else(|| "other".to_string()) - }; - - packages - .entry(package_name) - .or_default() - .entry(plugin_type) - .or_default() - .push(name.to_string()); - } - - for (package_name, types) in &packages { - println!("{}:", package_name.bold()); - for (type_name, plugin_names) in types { - println!(" {}:", type_name); - for plugin_name in plugin_names { - println!(" - {}", plugin_name); - } - } - println!(); - } - - println!("Run a plugin with:"); - println!(" r2x run plugin [args...]"); - println!(); - println!("Show plugin help:"); - println!(" r2x run plugin --show-help"); - - Ok(()) -} - -fn run_plugin(plugin_name: &str, args: &[String]) -> Result<(), RunError> { - logger::step(&format!("Running plugin: {}", plugin_name)); - logger::debug(&format!("Received args: {:?}", args)); - - let manifest = PluginManifest::load()?; - let plugin = manifest - .plugins - .get(plugin_name) - .ok_or_else(|| RunError::PluginNotFound(plugin_name.to_string()))?; - - // Verify packages are installed before running - logger::debug("Verifying packages..."); - package_verification::verify_and_ensure_plugin(&manifest, plugin_name) - .map_err(|e| RunError::Verification(e.to_string()))?; - logger::debug("Package verification complete"); - - // Parse arguments into config - let config_map = parse_plugin_args(args)?; - logger::debug(&format!("Parsed config_map: {:?}", config_map)); - let config_json = serde_json::to_string(&config_map) - .map_err(|e| RunError::Config(format!("Failed to serialize config: {}", e)))?; - - // Build call target - let target = build_call_target(plugin)?; - - // Initialize bridge and invoke plugin - let bridge = Bridge::get()?; - logger::debug(&format!("Invoking plugin with target: {}", target)); - logger::debug(&format!("Config: {}", config_json)); - - let result = bridge.invoke_plugin(&target, &config_json, None, Some(plugin))?; - - // Output result - if !result.is_empty() && result != "null" { - println!("{}", result); - logger::success("Plugin execution completed"); - } else { - logger::success("Plugin execution completed (no output)"); - } - - Ok(()) -} - -fn parse_plugin_args(args: &[String]) -> Result { - let mut config = serde_json::json!({}); - - for arg in args { - if let Some(eq_pos) = arg.find('=') { - let key = &arg[..eq_pos]; - let value_str = &arg[eq_pos + 1..]; - - // Convert hyphens to underscores for Python compatibility - let python_key = key.replace('-', "_"); - - let value = parse_json_value(value_str)?; - config[python_key] = value; - } else { - return Err(RunError::InvalidArgs(format!( - "Invalid argument format: '{}'. Expected key=value", - arg - ))); - } - } - - Ok(config) -} - -fn parse_json_value(value_str: &str) -> Result { - // Try to parse as JSON first - if let Ok(json_val) = serde_json::from_str::(value_str) { - return Ok(json_val); - } - - // Try parsing as boolean - match value_str.to_lowercase().as_str() { - "true" => return Ok(serde_json::json!(true)), - "false" => return Ok(serde_json::json!(false)), - _ => {} - } - - // Try parsing as number - if let Ok(num) = value_str.parse::() { - return Ok(serde_json::json!(num)); - } - - if let Ok(num) = value_str.parse::() { - return Ok(serde_json::json!(num)); - } - - // Default to string - Ok(serde_json::json!(value_str)) -} - -fn build_call_target(plugin: &crate::plugin_manifest::Plugin) -> Result { - let obj = plugin - .obj - .as_ref() - .ok_or_else(|| RunError::Config("Plugin missing callable metadata".to_string()))?; - - let target = if obj.callable_type == "class" { - let call_method = plugin - .call_method - .as_ref() - .ok_or_else(|| RunError::Config("Class plugin missing call_method".to_string()))?; - format!("{}:{}.{}", obj.module, obj.name, call_method) - } else { - format!("{}:{}", obj.module, obj.name) - }; - - Ok(target) -} - -fn list_pipelines(config: &PipelineConfig) { - let pipelines = config.list_pipelines(); - - if pipelines.is_empty() { - logger::warn("No pipelines found in YAML file"); - return; - } - - logger::step("Available Pipelines:"); - for name in pipelines { - if let Some(steps) = config.get_pipeline(&name) { - println!(" {} ({} steps)", name, steps.len()); - for step in steps { - println!(" - {}", step); - } - } - } -} - -fn print_pipeline_config(config: &PipelineConfig, pipeline_name: &str) -> Result<(), RunError> { - let output = config.print_pipeline_config(pipeline_name)?; - println!("{}", output); - Ok(()) -} - -fn show_pipeline_flow(config: &PipelineConfig, pipeline_name: &str) -> Result<(), RunError> { - let pipeline = config - .get_pipeline(pipeline_name) - .ok_or_else(|| PipelineError::PipelineNotFound(pipeline_name.to_string()))?; - - let manifest = PluginManifest::load()?; - - logger::success(&format!("Pipeline: {}", pipeline_name)); - println!(); - println!("Pipeline flow (--dry-run):"); - - for (index, plugin_name) in pipeline.iter().enumerate() { - let plugin = manifest - .plugins - .get(plugin_name) - .ok_or_else(|| RunError::PluginNotFound(plugin_name.to_string()))?; - - // Determine if plugin reads from stdin/stdout - let has_obj = plugin.obj.is_some(); - let input_marker = if index > 0 { "← stdin" } else { "" }; - let output_marker = if has_obj { "→ stdout" } else { "" }; - - print!(" {}", plugin_name); - if !input_marker.is_empty() { - print!(" {}", input_marker.dimmed()); - } - if !output_marker.is_empty() { - print!(" {}", output_marker.dimmed()); - } - println!(); - } - - println!(); - println!("{} No actual execution. Use without --dry-run to run the pipeline.", - "✔".green()); - - Ok(()) -} - -fn run_pipeline( - config: &PipelineConfig, - pipeline_name: &str, - output_file: Option<&str>, -) -> Result<(), RunError> { - let pipeline = config - .get_pipeline(pipeline_name) - .ok_or_else(|| PipelineError::PipelineNotFound(pipeline_name.to_string()))?; - - let manifest = PluginManifest::load()?; - let total_steps = pipeline.len(); - - // Verify all packages in pipeline before starting - logger::debug("Verifying packages for pipeline..."); - for plugin_name in pipeline.iter() { - package_verification::verify_and_ensure_plugin(&manifest, plugin_name) - .map_err(|e| RunError::Verification(e.to_string()))?; - } - logger::debug("All pipeline packages verified"); - - let pipeline_start = Instant::now(); - - eprintln!("{}", format!("Running: {}", pipeline_name).cyan().bold()); - - // Track stdin/stdout chain - let mut current_stdin: Option = None; - - for (idx, plugin_name) in pipeline.iter().enumerate() { - let step_num = idx + 1; - - // Start spinner for this step - logger::spinner_start(&format!(" {} [{}/{}]", plugin_name, step_num, total_steps)); - - let step_start = Instant::now(); - - let plugin = manifest - .plugins - .get(plugin_name) - .ok_or_else(|| RunError::PluginNotFound(plugin_name.to_string()))?; - - // Get plugin config from YAML - let yaml_config = if config.config.contains_key(plugin_name) { - config.get_plugin_config_json(plugin_name)? - } else { - "{}".to_string() - }; - - // Build proper config structure - let final_config_json = build_plugin_config(plugin, &yaml_config, &config.output_folder)?; - - // Determine if plugin uses stdin - let uses_stdin = matches!(plugin.io_type.as_deref(), Some("stdin") | Some("both")); - - let stdin_json = if uses_stdin { - current_stdin.as_deref() - } else { - None - }; - - let target = build_call_target(plugin)?; - - // Invoke plugin - let bridge = Bridge::get()?; - logger::debug(&format!("Invoking: {}", target)); - logger::debug(&format!("Config: {}", final_config_json)); - - let result = - match bridge.invoke_plugin(&target, &final_config_json, stdin_json, Some(plugin)) { - Ok(result) => { - let elapsed = step_start.elapsed(); - let elapsed_str = format_duration(elapsed); - logger::spinner_success(&format!( - "{} [{}/{}] ({})", - plugin_name, step_num, total_steps, elapsed_str - )); - result - } - Err(e) => { - let elapsed = step_start.elapsed(); - let elapsed_str = format_duration(elapsed); - logger::spinner_error(&format!( - "{} [{}/{}] ({})", - plugin_name, step_num, total_steps, elapsed_str - )); - return Err(RunError::Bridge(e)); - } - }; - - // Determine if plugin produces stdout - let produces_stdout = matches!(plugin.io_type.as_deref(), Some("stdout") | Some("both")); - - if produces_stdout && !result.is_empty() && result != "null" { - logger::debug(&format!("Plugin produced output ({} bytes)", result.len())); - current_stdin = Some(result); - } else { - logger::debug("Plugin produced no output or output not used"); - } - } - - let total_elapsed = pipeline_start.elapsed(); - let total_elapsed_str = format_duration(total_elapsed); - - eprintln!( - "{}", - format!("Finished in: {}", total_elapsed_str).green().bold() - ); - - // Handle final stdout output - if let Some(final_output) = current_stdin { - if let Some(output_path) = output_file { - // Save to file - logger::step(&format!("Writing output to: {}", output_path)); - std::fs::write(output_path, final_output.as_bytes()) - .map_err(|e| RunError::Pipeline(PipelineError::Io(e)))?; - logger::success(&format!("Output saved to: {}", output_path)); - } else { - // Print to stdout - println!("{}", final_output); - } - } - - Ok(()) -} - -fn format_duration(duration: std::time::Duration) -> String { - let total_ms = duration.as_millis(); - if total_ms < 1000 { - format!("{}ms", total_ms) - } else { - let secs = duration.as_secs_f64(); - format!("{:.2}s", secs) - } -} - -fn build_plugin_config( - plugin: &crate::plugin_manifest::Plugin, - yaml_config_json: &str, - output_folder: &Option, -) -> Result { - let yaml_config: serde_json::Value = serde_json::from_str(yaml_config_json) - .map_err(|e| RunError::Config(format!("Failed to parse YAML config: {}", e)))?; - - let mut final_config = serde_json::Map::new(); - - // Check if this is a class with a config parameter - if let Some(obj) = &plugin.obj { - if obj.callable_type == "class" { - let mut config_class_params = serde_json::Map::new(); - let mut constructor_params = serde_json::Map::new(); - - // Get config class parameter names - let config_param_names: std::collections::HashSet = - if let Some(config_meta) = &plugin.config { - config_meta.parameters.keys().cloned().collect() - } else { - std::collections::HashSet::new() - }; - - // Separate YAML params into config class vs constructor - if let serde_json::Value::Object(ref yaml_map) = yaml_config { - for (key, value) in yaml_map { - if key == "store" { - continue; - } else if config_param_names.contains(key) { - config_class_params.insert(key.clone(), value.clone()); - } else if obj.parameters.contains_key(key) { - constructor_params.insert(key.clone(), value.clone()); - } else { - config_class_params.insert(key.clone(), value.clone()); - } - } - } - - // If we have config class params, nest them under 'config' key - if !config_class_params.is_empty() && obj.parameters.contains_key("config") { - final_config.insert( - "config".to_string(), - serde_json::Value::Object(config_class_params), - ); - } - - // Add constructor params at top level - final_config.extend(constructor_params); - - // Handle data_store if required - if obj.parameters.contains_key("data_store") { - let store_value = if let serde_json::Value::Object(ref yaml_map) = yaml_config { - if let Some(store) = yaml_map.get("store") { - store.clone() - } else { - let output_folder = output_folder.as_deref().unwrap_or("/tmp/r2x-output"); - let store_path = format!("{}/store", output_folder); - std::fs::create_dir_all(&store_path).map_err(|e| { - RunError::Config(format!("Failed to create store directory: {}", e)) - })?; - - serde_json::json!({ - "path": store_path, - "name": plugin.package_name.as_deref().unwrap_or("default"), - }) - } - } else { - let output_folder = output_folder.as_deref().unwrap_or("/tmp/r2x-output"); - let store_path = format!("{}/store", output_folder); - std::fs::create_dir_all(&store_path).map_err(|e| { - RunError::Config(format!("Failed to create store directory: {}", e)) - })?; - - serde_json::json!({ - "path": store_path, - "name": plugin.package_name.as_deref().unwrap_or("default"), - }) - }; - - final_config.insert("data_store".to_string(), store_value); - } - } else if let serde_json::Value::Object(ref yaml_map) = yaml_config { - final_config.extend(yaml_map.clone()); - } - } else if let serde_json::Value::Object(ref yaml_map) = yaml_config { - final_config.extend(yaml_map.clone()); - } - - serde_json::to_string(&serde_json::Value::Object(final_config)) - .map_err(|e| RunError::Config(format!("Failed to serialize final config: {}", e))) -} - -#[cfg(test)] -mod tests { - #[test] - fn test_run_command() { - // Integration tests for run command - } -} diff --git a/src/errors.rs b/src/errors.rs deleted file mode 100644 index da06c6f..0000000 --- a/src/errors.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! Centralized error types for the r2x project -//! -//! This module defines all error types used across the project, -//! providing a unified error handling interface. - -use std::io; -use std::path::PathBuf; -use thiserror::Error; - -/// Errors that can occur during Python bridge operations -#[derive(Error, Debug)] -pub enum BridgeError { - #[error("Python error: {0}")] - Python(String), - - #[error("Failed to import module '{0}': {1}")] - Import(String, String), - - #[error("Python venv not found or invalid at: {0}")] - VenvNotFound(PathBuf), - - #[error("r2x-core is not installed in the Python environment")] - R2XCoreNotInstalled, - - #[error("Failed to serialize/deserialize data: {0}")] - Serialization(String), - - #[error("Failed to initialize Python interpreter: {0}")] - Initialization(String), - - #[error("Plugin '{0}' not found")] - PluginNotFound(String), - - #[error("Invalid entry point format: {0}")] - InvalidEntryPoint(String), - - #[error("IO error: {0}")] - Io(#[from] io::Error), -} - -impl From for BridgeError { - fn from(err: pyo3::PyErr) -> Self { - BridgeError::Python(format!("{}", err)) - } -} - -/// Errors that can occur during pipeline configuration operations -#[derive(Error, Debug)] -pub enum PipelineError { - #[error("IO error: {0}")] - Io(#[from] io::Error), - - #[error("Failed to parse pipeline YAML: {0}")] - Parse(#[from] serde_yaml::Error), - - #[error("Variable '{0}' not found in variables section")] - VariableNotFound(String), - - #[error("Pipeline '{0}' not found in YAML")] - PipelineNotFound(String), - - #[error("Invalid configuration: {0}")] - InvalidConfig(String), -} - -/// Errors that can occur during plugin manifest operations -#[derive(Error, Debug)] -pub enum ManifestError { - #[error("IO error: {0}")] - Io(#[from] io::Error), - - #[error("Failed to parse manifest: {0}")] - Parse(#[from] toml::de::Error), - - #[error("Failed to serialize manifest: {0}")] - Serialize(#[from] toml::ser::Error), - - #[error("Invalid plugin: {0}")] - InvalidPlugin(String), -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_bridge_error_display() { - let err = BridgeError::PluginNotFound("test-plugin".to_string()); - assert_eq!(err.to_string(), "Plugin 'test-plugin' not found"); - } - - #[test] - fn test_pipeline_error_display() { - let err = PipelineError::PipelineNotFound("test-pipeline".to_string()); - assert_eq!( - err.to_string(), - "Pipeline 'test-pipeline' not found in YAML" - ); - } - - #[test] - fn test_manifest_error_display() { - let err = ManifestError::InvalidPlugin("test".to_string()); - assert_eq!(err.to_string(), "Invalid plugin: test"); - } -} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index ddee040..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! R2X library - expose modules for testing -//! -//! This library exposes core modules needed for testing and integration. - -pub mod config_manager; -pub mod errors; -pub mod logger; -pub mod package_verification; -pub mod plugin_cache; -pub mod plugin_manifest; -pub mod python_bridge; - -// Re-export common error types for convenience -pub use errors::{BridgeError, ManifestError, PipelineError}; diff --git a/src/plugin_cache.rs b/src/plugin_cache.rs deleted file mode 100644 index 433dec6..0000000 --- a/src/plugin_cache.rs +++ /dev/null @@ -1,493 +0,0 @@ -use crate::config_manager::Config; -use crate::logger; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fs; -use std::path::PathBuf; - -/// Top-level package containing plugins (matches Python Package from RUST_PYTHON_INTEROP.md) -/// -/// This structure represents the serialized plugin package as defined in the -/// Python serialization spec, enabling type-safe Rust/Python interop. -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct CachedPackage { - /// Package name (e.g., "r2x-reeds", "r2x-plexos") - pub name: String, - - /// List of plugins in this package - pub plugins: Vec, - - /// Package metadata (version, author, etc.) as JSON for flexibility - #[serde(default)] - pub metadata: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct CachedPlugin { - /// Plugin name (display name, not type-prefixed) - pub name: String, - - /// Callable object metadata (class or function) - #[serde(skip_serializing_if = "Option::is_none")] - pub obj: Option, - - /// Plugin type: "parser", "exporter", "sysmod", "upgrader" - #[serde(rename = "plugin_type")] - pub plugin_type: String, - - /// Configuration class metadata (if applicable) - #[serde(skip_serializing_if = "Option::is_none")] - pub config: Option, - - /// Method to call on the callable object (e.g., "build_system", "export") - #[serde(skip_serializing_if = "Option::is_none")] - pub call_method: Option, -} - -/// Callable object metadata (matches Python Callable from RUST_PYTHON_INTEROP.md) -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct CallableMetadata { - /// Python module path (e.g., "r2x_reeds.parser") - pub module: String, - - /// Callable name (e.g., "ReEDSParser" or "parse_function") - pub name: String, - - /// Callable type: "class" or "function" - #[serde(rename = "type")] - pub callable_type: String, - - /// Return type annotation (e.g., "System", "None") - #[serde(skip_serializing_if = "Option::is_none")] - pub return_annotation: Option, - - /// Parameter metadata keyed by parameter name - #[serde(default)] - #[serde(skip_serializing_if = "HashMap::is_empty")] - pub parameters: HashMap, -} - -/// Parameter metadata for callable or config -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct ParameterMetadata { - /// Type annotation as string (e.g., "str | None", "int", "System") - #[serde(skip_serializing_if = "Option::is_none")] - pub annotation: Option, - - /// Default value as JSON (can be any JSON type: null, number, string, object, array) - #[serde(skip_serializing_if = "Option::is_none")] - pub default: Option, - - /// Whether this parameter is required - pub is_required: bool, -} - -impl CachedPackage { - /// Create a new cached package - pub fn new(name: String) -> Self { - CachedPackage { - name, - plugins: Vec::new(), - metadata: HashMap::new(), - } - } - - /// Add a plugin to the package - pub fn add_plugin(&mut self, plugin: CachedPlugin) { - logger::debug(&format!("Added {} to cache", &plugin.name)); - self.plugins.push(plugin); - } - - /// Get a plugin by name - pub fn get_plugin(&self, name: &str) -> Option<&CachedPlugin> { - self.plugins.iter().find(|p| p.name == name) - } -} - -/// Cache entry for a specific package version -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct CacheEntry { - /// Package version - pub version: String, - - /// Cached package containing plugins and metadata - pub package: CachedPackage, - - /// Timestamp when cached (RFC 3339) - pub cached_at: String, -} - -/// Plugin metadata cache -/// -/// Stores plugin packages keyed by package name and version. Each entry is versioned -/// to enable cache invalidation when package versions change. -#[derive(Serialize, Deserialize, Debug, Clone, Default)] -pub struct PluginMetadataCache { - #[serde(default)] - pub entries: HashMap, -} - -impl PluginMetadataCache { - /// Get the path to the metadata cache file - pub fn cache_file_path() -> Result { - let config = Config::load().map_err(|e| format!("Failed to load config: {}", e))?; - let cache_path = config.get_cache_path(); - Ok(PathBuf::from(cache_path).join("plugin_metadata.toml")) - } - - /// Load cache from disk, returning empty cache if file doesn't exist - pub fn load() -> Result { - let path = Self::cache_file_path()?; - - if !path.exists() { - return Ok(PluginMetadataCache::default()); - } - - let content = - fs::read_to_string(&path).map_err(|e| format!("Failed to read cache file: {}", e))?; - - let cache: PluginMetadataCache = - toml::from_str(&content).map_err(|e| format!("Failed to parse cache: {}", e))?; - - Ok(cache) - } - - /// Save cache to disk - pub fn save(&self) -> Result<(), String> { - let path = Self::cache_file_path()?; - - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create cache directory: {}", e))?; - } - - let content = toml::to_string_pretty(self) - .map_err(|e| format!("Failed to serialize cache: {}", e))?; - - fs::write(&path, content).map_err(|e| format!("Failed to write cache file: {}", e))?; - - Ok(()) - } - - /// Get cached package by name and version (NEW API) - /// - /// Returns the cached package if found and version matches. - /// Returns None if version mismatch (cache miss) or package not found. - pub fn get_package(&self, package_name: &str, version: &str) -> Option<&CachedPackage> { - self.entries.get(package_name).and_then(|entry| { - if entry.version == version { - Some(&entry.package) - } else { - None - } - }) - } - - /// Store package in cache (NEW API) - pub fn set_package( - &mut self, - package_name: String, - version: String, - package: CachedPackage, - ) -> Result<(), String> { - let now = chrono::Utc::now().to_rfc3339(); - - self.entries.insert( - package_name, - CacheEntry { - version, - package, - cached_at: now, - }, - ); - - Ok(()) - } - - /// Extract plugins from cached package as manifest plugins (CONVERSION HELPER) - /// - /// Converts CachedPlugin objects to plugin_manifest::Plugin objects. - /// Used during cache hit to convert typed cache data back to manifest format. - pub fn extract_plugins( - cached_package: &CachedPackage, - ) -> Vec<(String, crate::plugin_manifest::Plugin)> { - cached_package - .plugins - .iter() - .map(|cached_plugin| { - let plugin = Self::convert_cached_to_manifest(cached_plugin); - (cached_plugin.name.clone(), plugin) - }) - .collect() - } - - /// Convert CachedPlugin to manifest Plugin (INTERNAL CONVERSION) - /// - /// Translates from the cache representation to the manifest representation. - /// The caller is responsible for setting package_name and install_type. - fn convert_cached_to_manifest(cached: &CachedPlugin) -> crate::plugin_manifest::Plugin { - crate::plugin_manifest::Plugin { - package_name: None, // Set by caller - plugin_type: Some(cached.plugin_type.clone()), - description: None, - doc: None, - io_type: None, - call_method: cached.call_method.clone(), - requires_store: None, - obj: cached.obj.as_ref().map(|obj| { - let parameters = obj - .parameters - .iter() - .map(|(k, v)| { - ( - k.clone(), - crate::plugin_manifest::ParameterMetadata { - annotation: v.annotation.clone(), - default: v - .default - .as_ref() - .map(|d| serde_json::to_string(d).unwrap_or_default()), - is_required: v.is_required, - }, - ) - }) - .collect(); - - crate::plugin_manifest::CallableMetadata { - module: obj.module.clone(), - name: obj.name.clone(), - callable_type: obj.callable_type.clone(), - return_annotation: obj.return_annotation.clone(), - parameters, - } - }), - config: cached.config.as_ref().and_then(|config_json| { - // Try to deserialize config JSON to ConfigMetadata - serde_json::from_value::( - config_json.clone(), - ) - .ok() - }), - upgrader: None, - install_type: None, - installed_by: None, - } - } - - /// Clear the entire cache - pub fn clear() -> Result<(), String> { - let path = Self::cache_file_path()?; - - if path.exists() { - fs::remove_file(&path).map_err(|e| format!("Failed to delete cache file: {}", e))?; - } - - Ok(()) - } - - /// Check if cache has an entry for a package - pub fn has_entry(&self, package_name: &str) -> bool { - self.entries.contains_key(package_name) - } - - /// Remove a package entry from cache (but keep cache file) - pub fn remove_entry(&mut self, package_name: &str) -> bool { - self.entries.remove(package_name).is_some() - } - - /// Get cache statistics for debugging - pub fn stats(&self) -> CacheStats { - CacheStats { - total_entries: self.entries.len(), - packages: self.entries.keys().cloned().collect(), - } - } -} - -/// Cache statistics -#[derive(Debug, Clone)] -pub struct CacheStats { - pub total_entries: usize, - pub packages: Vec, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_cache_new() { - let cache = PluginMetadataCache::default(); - assert_eq!(cache.entries.len(), 0); - } - - #[test] - fn test_cached_package_creation() { - let mut package = CachedPackage::new("r2x-test".to_string()); - assert_eq!(package.name, "r2x-test"); - assert_eq!(package.plugins.len(), 0); - - let plugin = CachedPlugin { - name: "test-plugin".to_string(), - obj: None, - plugin_type: "parser".to_string(), - config: None, - call_method: Some("build".to_string()), - }; - - package.add_plugin(plugin.clone()); - assert_eq!(package.plugins.len(), 1); - assert!(package.get_plugin("test-plugin").is_some()); - } - - #[test] - fn test_cached_package_serialization() { - let mut package = CachedPackage::new("r2x-test".to_string()); - package - .metadata - .insert("version".to_string(), serde_json::json!("1.0.0")); - - let plugin = CachedPlugin { - name: "test-parser".to_string(), - obj: Some(CallableMetadata { - module: "test_module".to_string(), - name: "TestParser".to_string(), - callable_type: "class".to_string(), - return_annotation: Some("System".to_string()), - parameters: HashMap::new(), - }), - plugin_type: "parser".to_string(), - config: None, - call_method: Some("build_system".to_string()), - }; - - package.add_plugin(plugin); - - // Serialize to JSON and back - let json = serde_json::to_string(&package).expect("Failed to serialize"); - let restored: CachedPackage = serde_json::from_str(&json).expect("Failed to deserialize"); - - assert_eq!(restored.name, package.name); - assert_eq!(restored.plugins.len(), package.plugins.len()); - } - - #[test] - fn test_cache_set_get_package() { - let mut cache = PluginMetadataCache::default(); - let package = CachedPackage::new("r2x-test".to_string()); - - cache - .set_package("r2x-test".to_string(), "1.0.0".to_string(), package) - .unwrap(); - - let retrieved = cache.get_package("r2x-test", "1.0.0"); - assert!(retrieved.is_some()); - assert_eq!(retrieved.unwrap().name, "r2x-test"); - } - - #[test] - fn test_cache_version_mismatch() { - let mut cache = PluginMetadataCache::default(); - let package = CachedPackage::new("r2x-test".to_string()); - - cache - .set_package("r2x-test".to_string(), "1.0.0".to_string(), package) - .unwrap(); - - // Different version should not match - let retrieved = cache.get_package("r2x-test", "2.0.0"); - assert!(retrieved.is_none()); - } - - #[test] - fn test_extract_plugins() { - let mut package = CachedPackage::new("r2x-test".to_string()); - - let plugin = CachedPlugin { - name: "test-parser".to_string(), - obj: Some(CallableMetadata { - module: "test.parser".to_string(), - name: "Parser".to_string(), - callable_type: "class".to_string(), - return_annotation: None, - parameters: HashMap::new(), - }), - plugin_type: "parser".to_string(), - config: None, - call_method: Some("build_system".to_string()), - }; - - package.add_plugin(plugin); - - let plugins = PluginMetadataCache::extract_plugins(&package); - assert_eq!(plugins.len(), 1); - assert_eq!(plugins[0].0, "test-parser"); - } - - #[test] - fn test_cache_remove_entry() { - let mut cache = PluginMetadataCache::default(); - let package = CachedPackage::new("r2x-test".to_string()); - - cache - .set_package("r2x-test".to_string(), "1.0.0".to_string(), package) - .unwrap(); - - assert!(cache.has_entry("r2x-test")); - assert!(cache.remove_entry("r2x-test")); - assert!(!cache.has_entry("r2x-test")); - } - - #[test] - fn test_cache_stats() { - let mut cache = PluginMetadataCache::default(); - let package = CachedPackage::new("r2x-test".to_string()); - - cache - .set_package("r2x-test".to_string(), "1.0.0".to_string(), package) - .unwrap(); - - let stats = cache.stats(); - assert_eq!(stats.total_entries, 1); - assert!(stats.packages.contains(&"r2x-test".to_string())); - } - - #[test] - fn test_cache_serialization_round_trip() { - let mut cache = PluginMetadataCache::default(); - - let mut package = CachedPackage::new("r2x-test".to_string()); - package - .metadata - .insert("version".to_string(), serde_json::json!("0.1.0")); - - let plugin = CachedPlugin { - name: "test-parser".to_string(), - obj: Some(CallableMetadata { - module: "test.parser".to_string(), - name: "TestParser".to_string(), - callable_type: "class".to_string(), - return_annotation: None, - parameters: HashMap::new(), - }), - plugin_type: "parser".to_string(), - config: None, - call_method: Some("build_system".to_string()), - }; - - package.add_plugin(plugin); - - cache - .set_package("r2x-test".to_string(), "0.1.0".to_string(), package) - .unwrap(); - - // Simulate serialization round-trip - let toml_str = toml::to_string_pretty(&cache).expect("Failed to serialize to TOML"); - let restored: PluginMetadataCache = - toml::from_str(&toml_str).expect("Failed to deserialize from TOML"); - - let pkg = restored.get_package("r2x-test", "0.1.0"); - assert!(pkg.is_some()); - assert_eq!(pkg.unwrap().plugins.len(), 1); - } -} diff --git a/src/plugin_manifest.rs b/src/plugin_manifest.rs deleted file mode 100644 index f717e3c..0000000 --- a/src/plugin_manifest.rs +++ /dev/null @@ -1,366 +0,0 @@ -use crate::errors::ManifestError; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; - -/// Plugin metadata from package serialization -/// -/// This structure breaks down plugin metadata into concise, queryable fields -/// while preserving full JSON metadata for invocation. -/// -/// Note: The plugin name is stored as the HashMap key in PluginManifest, -/// not duplicated here to avoid inconsistency -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Plugin { - /// Package name this plugin belongs to (e.g., "r2x-reeds") - #[serde(skip_serializing_if = "Option::is_none")] - pub package_name: Option, - - /// Plugin type: "parser", "exporter", "sysmod", "upgrader" - #[serde(skip_serializing_if = "Option::is_none")] - pub plugin_type: Option, - - /// Brief description of what the plugin does - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Plugin documentation string - #[serde(skip_serializing_if = "Option::is_none")] - pub doc: Option, - - /// IO type: "stdin", "stdout", "both", or null - #[serde(skip_serializing_if = "Option::is_none")] - pub io_type: Option, - - /// Method to call on the callable object (e.g., "build_system", "export") - #[serde(skip_serializing_if = "Option::is_none")] - pub call_method: Option, - - /// Whether this plugin requires a data store - #[serde(skip_serializing_if = "Option::is_none")] - pub requires_store: Option, - - /// Callable object metadata - #[serde(skip_serializing_if = "Option::is_none")] - pub obj: Option, - - /// Configuration schema metadata - #[serde(skip_serializing_if = "Option::is_none")] - pub config: Option, - - /// Upgrader-specific metadata - #[serde(skip_serializing_if = "Option::is_none")] - pub upgrader: Option, - - /// Installation type: "explicit" (user-installed) or "dependency" (transitively installed) - #[serde(skip_serializing_if = "Option::is_none")] - pub install_type: Option, - - /// Name of the package that caused this plugin to be installed as a dependency - /// Only set when install_type is "dependency" - #[serde(skip_serializing_if = "Option::is_none")] - pub installed_by: Option, -} - -/// Callable object metadata (parsed from obj JSON) -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct CallableMetadata { - /// Python module path (e.g., "r2x_reeds.parser") - pub module: String, - - /// Callable name (e.g., "ReEDSParser") - pub name: String, - - /// Callable type: "class" or "function" - #[serde(rename = "type")] - pub callable_type: String, - - /// Return annotation (e.g., "None", "System") - #[serde(skip_serializing_if = "Option::is_none")] - pub return_annotation: Option, - - /// Parameters as a map of parameter name to metadata - #[serde(default)] - #[serde(skip_serializing_if = "HashMap::is_empty")] - pub parameters: HashMap, -} - -/// Configuration schema metadata (parsed from config JSON) -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct ConfigMetadata { - /// Config module path (e.g., "r2x_reeds.config") - pub module: String, - - /// Config class name (e.g., "ReEDSConfig") - pub name: String, - - /// Return annotation - #[serde(skip_serializing_if = "Option::is_none")] - pub return_annotation: Option, - - /// Config parameters as a map of parameter name to metadata - #[serde(default)] - #[serde(skip_serializing_if = "HashMap::is_empty")] - pub parameters: HashMap, -} - -/// Parameter metadata for a callable or config -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct ParameterMetadata { - /// Type annotation (e.g., "str | None", "int", "System") - #[serde(skip_serializing_if = "Option::is_none")] - pub annotation: Option, - - /// Default value as JSON string (e.g., "null", "true", "5", "\"base\"") - #[serde(skip_serializing_if = "Option::is_none")] - pub default: Option, - - /// Whether this parameter is required - pub is_required: bool, -} - -/// Upgrader-specific metadata -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct UpgraderMetadata { - /// Version strategy as JSON (kept as JSON for now due to complexity) - #[serde(skip_serializing_if = "Option::is_none")] - pub version_strategy_json: Option, - - /// Version reader as JSON (kept as JSON for now due to complexity) - #[serde(skip_serializing_if = "Option::is_none")] - pub version_reader_json: Option, - - /// Upgrade steps as JSON (kept as JSON for now due to complexity) - #[serde(skip_serializing_if = "Option::is_none")] - pub upgrade_steps_json: Option, -} - -/// NOTE: package-level registry removed — manifest now stores only `plugins`. -/// The old `PluginPackage` type has been removed to simplify the registry. -/// Plugin registry manifest -/// -/// **WARNING: This file is auto-managed by the r2x CLI.** -/// **Do not edit manually - use `r2x plugins` commands instead.** -/// -/// The manifest tracks installed plugins and their metadata for dynamic CLI generation. -#[derive(Serialize, Deserialize, Debug, Clone, Default)] -pub struct PluginManifest { - #[serde(default)] - pub plugins: HashMap, -} - -impl Plugin { - /// Validate plugin metadata - fn validate(&self, _name: &str) -> Result<(), ManifestError> { - // Description is optional now, so no validation needed - // All other fields are optional and derived from package metadata - Ok(()) - } -} - -impl PluginManifest { - /// Get the path to the manifest file - pub fn path() -> PathBuf { - // On Unix/macOS: use ~/.cache/r2x/manifest.toml - // On Windows: use AppData/Local/r2x/manifest.toml - #[cfg(not(target_os = "windows"))] - { - dirs::home_dir() - .expect("Could not determine home directory") - .join(".cache") - .join("r2x") - .join("manifest.toml") - } - - #[cfg(target_os = "windows")] - { - dirs::cache_dir() - .expect("Could not determine cache directory") - .join("r2x") - .join("manifest.toml") - } - } - - /// Load manifest from disk, returning empty manifest if file doesn't exist - pub fn load() -> Result { - let path = Self::path(); - if !path.exists() { - return Ok(PluginManifest::default()); - } - - let content = std::fs::read_to_string(&path)?; - let manifest: PluginManifest = toml::from_str(&content)?; - - // Validate all plugins on load (fail fast) - for (name, plugin) in &manifest.plugins { - plugin.validate(name)?; - } - - // No package-level validation — manifest contains only `plugins` now. - - Ok(manifest) - } - - /// Save manifest to disk with validation - pub fn save(&self) -> Result<(), ManifestError> { - // Validate before saving (fail fast) - for (name, plugin) in &self.plugins { - plugin.validate(name)?; - } - - // No package-level validation required. - - let path = Self::path(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - - let content = toml::to_string_pretty(self)?; - std::fs::write(&path, content)?; - Ok(()) - } - - /// Add or update a plugin in the manifest - pub fn add_plugin(&mut self, name: String, plugin: Plugin) -> Result<(), ManifestError> { - plugin.validate(&name)?; - self.plugins.insert(name, plugin); - Ok(()) - } - - /// Remove a plugin from the manifest - pub fn remove_plugin(&mut self, name: &str) -> bool { - self.plugins.remove(name).is_some() - } - - /// Remove all plugins belonging to a package from the manifest - /// Returns the number of plugins removed - pub fn remove_plugins_by_package(&mut self, package_name: &str) -> usize { - let to_remove: Vec = self - .plugins - .iter() - .filter(|(_, plugin)| { - plugin - .package_name - .as_ref() - .map(|pkg| pkg == package_name) - .unwrap_or(false) - }) - .map(|(name, _)| name.clone()) - .collect(); - - let count = to_remove.len(); - for name in to_remove { - self.plugins.remove(&name); - } - count - } - - /// Get a plugin by name - pub fn get_plugin(&self, name: &str) -> Option<&Plugin> { - self.plugins.get(name) - } - - /// List all plugins with their names - pub fn list_plugins(&self) -> Vec<(&str, &Plugin)> { - self.plugins.iter().map(|(k, v)| (k.as_str(), v)).collect() - } - - /// Check if manifest has no plugins - pub fn is_empty(&self) -> bool { - self.plugins.is_empty() - } - - /// Check if a specific plugin exists - pub fn has_plugin(&self, name: &str) -> bool { - self.plugins.contains_key(name) - } -} - -impl PluginManifest { - /// Serialize this PluginManifest to a JSON string - /// Return a pretty JSON string representation of the manifest. This is - /// intended for fast rendering by CLI/UI consumers that prefer JSON. - /// On serialization errors this returns an empty JSON object ("{}"). - pub fn to_json_string(&self) -> String { - serde_json::to_string_pretty(&self).unwrap_or_else(|_| "{}".to_string()) - } - - /// Save the manifest as JSON to the given path. Returns filesystem IO - /// errors via `ManifestError::Io`. - pub fn save_json>(&self, path: P) -> Result<(), ManifestError> { - let s = serde_json::to_string_pretty(&self).unwrap_or_else(|_| "{}".to_string()); - std::fs::write(path, s).map_err(ManifestError::Io)?; - Ok(()) - } - - /// Return the manifest JSON for CLI/UI consumers without initializing Python. - /// Loads the manifest from disk and returns pretty JSON. On error returns "{}". - pub fn get_manifest_json() -> String { - match PluginManifest::load() { - Ok(manifest) => { - serde_json::to_string_pretty(&manifest).unwrap_or_else(|_| "{}".to_string()) - } - Err(_) => "{}".to_string(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_manifest_new() { - let manifest = PluginManifest::default(); - assert!(manifest.is_empty()); - } - - #[test] - fn test_add_plugin() { - let mut manifest = PluginManifest::default(); - let plugin = Plugin { - package_name: None, - plugin_type: None, - description: None, - doc: None, - io_type: None, - call_method: None, - requires_store: None, - obj: None, - config: None, - upgrader: None, - install_type: None, - installed_by: None, - }; - manifest - .add_plugin("test-plugin".to_string(), plugin) - .unwrap(); - assert!(!manifest.is_empty()); - assert!(manifest.has_plugin("test-plugin")); - } - - #[test] - fn test_remove_plugin() { - let mut manifest = PluginManifest::default(); - let plugin = Plugin { - package_name: None, - plugin_type: None, - description: None, - doc: None, - io_type: None, - call_method: None, - requires_store: None, - obj: None, - config: None, - upgrader: None, - install_type: None, - installed_by: None, - }; - manifest - .add_plugin("test-plugin".to_string(), plugin) - .unwrap(); - assert!(manifest.remove_plugin("test-plugin")); - assert!(manifest.is_empty()); - assert!(!manifest.remove_plugin("non-existent")); - } -} diff --git a/src/plugins/clean.rs b/src/plugins/clean.rs deleted file mode 100644 index e7893a2..0000000 --- a/src/plugins/clean.rs +++ /dev/null @@ -1,55 +0,0 @@ -use crate::logger; -use crate::plugin_cache::PluginMetadataCache; -use crate::plugin_manifest::PluginManifest; -use crate::GlobalOpts; -use colored::*; - -pub fn clean_manifest(yes: bool, _opts: &GlobalOpts) -> Result<(), String> { - let mut manifest = - PluginManifest::load().map_err(|e| format!("Failed to load manifest: {}", e))?; - - if manifest.is_empty() { - logger::warn("Manifest is empty."); - return Ok(()); - } - - let total = manifest.plugins.len(); - logger::debug(&format!("Manifest has {} plugin entries.", total)); - - if !yes { - println!("To actually clean, run with --yes flag."); - return Ok(()); - } - - manifest.plugins.clear(); - manifest - .save() - .map_err(|e| format!("Failed to save manifest: {}", e))?; - - // Also clear the metadata cache when cleaning manifest - if let Err(e) = PluginMetadataCache::clear() { - logger::debug(&format!("Note: Failed to clear metadata cache: {}", e)); - } - - println!("{}", format!("Removed {} plugin(s)", total).dimmed()); - - Ok(()) -} - -#[cfg(test)] -mod tests { - #[test] - fn test_clean_manifest_empty() { - // Test cleaning empty manifest - } - - #[test] - fn test_clean_manifest_with_plugins() { - // Test cleaning manifest with plugins - } - - #[test] - fn test_clean_manifest_requires_yes_flag() { - // Test that --yes flag is required - } -} diff --git a/src/plugins/discovery.rs b/src/plugins/discovery.rs deleted file mode 100644 index 44a7410..0000000 --- a/src/plugins/discovery.rs +++ /dev/null @@ -1,482 +0,0 @@ -use crate::config_manager::Config; -use crate::logger; -use crate::plugin_cache::{CachedPackage, CachedPlugin, PluginMetadataCache}; -use crate::plugin_manifest::PluginManifest; -use crate::python_bridge; -use std::fs; -use std::path::PathBuf; - -/// Options for plugin discovery and registration -pub struct DiscoveryOptions { - pub package: String, - pub package_name_full: String, - pub dependencies: Vec, - pub package_version: Option, - pub no_cache: bool, -} - -pub fn discover_and_register_entry_points_with_deps( - _uv_path: &str, - _python_path: &str, - opts: DiscoveryOptions, -) -> Result { - let package = &opts.package; - let package_name_full = &opts.package_name_full; - let dependencies = &opts.dependencies; - let no_cache = opts.no_cache; - - logger::debug(&format!("Registering plugins from package: '{}'", package)); - - // Extract short name for entry point lookup (e.g., "reeds" from "r2x-reeds") - let package_short_name = if package_name_full.starts_with("r2x-") { - package_name_full.trim_start_matches("r2x-") - } else { - package_name_full - }; - - logger::debug(&format!( - "Full package name: {}, short name: {}", - package_name_full, package_short_name - )); - - // Quick check: verify entry_points.txt exists before initializing Python bridge - // This avoids 1.9s+ Python initialization for packages without plugins - let has_entry_points = check_entry_points_exists(package_name_full); - - if !has_entry_points { - logger::debug(&format!( - "No entry_points.txt found for {} - skipping plugin load", - package_name_full - )); - return Ok(0); - } - - // Load or create manifest early to check cache - let mut manifest = match PluginManifest::load() { - Ok(m) => m, - Err(e) => { - logger::warn(&format!("Failed to load manifest: {}", e)); - PluginManifest::default() - } - }; - - // Check if we already have plugins for this package in the manifest - let existing_plugins: Vec = manifest - .plugins - .iter() - .filter(|(_, plugin)| plugin.package_name.as_deref() == Some(package_name_full)) - .map(|(key, _)| key.clone()) - .collect(); - - // Check metadata cache with version-aware lookup - let plugin_entries = if !existing_plugins.is_empty() { - logger::debug(&format!( - "Found {} plugin(s) in active manifest for '{}', reusing", - existing_plugins.len(), - package_name_full - )); - // Use existing plugins from manifest instead of reloading - existing_plugins - .iter() - .filter_map(|key| manifest.plugins.get(key).map(|p| (key.clone(), p.clone()))) - .collect() - } else { - // Try metadata cache with version lookup - let cache_version = opts.package_version.as_deref().unwrap_or("unknown"); - let mut metadata_cache = PluginMetadataCache::load().unwrap_or_else(|e| { - logger::debug(&format!("Failed to load metadata cache: {}", e)); - PluginMetadataCache::default() - }); - - if !no_cache - && metadata_cache - .get_package(package_name_full, cache_version) - .is_some() - { - let cached_package = metadata_cache - .get_package(package_name_full, cache_version) - .unwrap(); - logger::debug(&format!( - "✓ Cache hit: Found {} plugin(s) for '{}@{}' in metadata cache", - cached_package.plugins.len(), - package_name_full, - cache_version - )); - - PluginMetadataCache::extract_plugins(cached_package) - } else { - // Cache miss or --no-cache - load from bridge and cache for future use - if no_cache { - logger::debug(&format!( - "⊘ Cache skipped (--no-cache): Loading '{}@{}' from package metadata", - package_name_full, cache_version - )); - } else { - logger::debug(&format!( - "✗ Cache miss: Loading '{}@{}' from package metadata", - package_name_full, cache_version - )); - } - let bridge = python_bridge::Bridge::get() - .map_err(|e| format!("Failed to initialize Python bridge: {}", e))?; - - let plugin_entries = bridge - .build_manifest_from_package(package_short_name, package_name_full) - .map_err(|e| { - format!( - "Failed to load plugin package '{}': {}", - package_short_name, e - ) - })?; - - // Cache the plugins for future installs - if !plugin_entries.is_empty() { - let mut cached_package = CachedPackage::new(package_name_full.to_string()); - - for (name, plugin) in &plugin_entries { - let cached_plugin = CachedPlugin { - name: name.clone(), - obj: plugin - .obj - .as_ref() - .map(|obj| crate::plugin_cache::CallableMetadata { - module: obj.module.clone(), - name: obj.name.clone(), - callable_type: obj.callable_type.clone(), - return_annotation: obj.return_annotation.clone(), - parameters: obj - .parameters - .iter() - .map(|(k, v)| { - ( - k.clone(), - crate::plugin_cache::ParameterMetadata { - annotation: v.annotation.clone(), - default: v.default.as_ref().and_then(|d| { - serde_json::from_str::(d) - .ok() - .filter(|val| !val.is_null()) - }), - is_required: v.is_required, - }, - ) - }) - .collect(), - }), - plugin_type: plugin.plugin_type.clone().unwrap_or_default(), - config: plugin - .config - .as_ref() - .and_then(|c| serde_json::to_value(c).ok()), - call_method: plugin.call_method.clone(), - }; - cached_package.add_plugin(cached_plugin); - } - - if let Err(e) = metadata_cache.set_package( - package_name_full.to_string(), - cache_version.to_string(), - cached_package, - ) { - logger::debug(&format!( - "Warning: Failed to cache plugin metadata for '{}@{}': {}", - package_name_full, cache_version, e - )); - } else if let Err(e) = metadata_cache.save() { - logger::debug(&format!("Warning: Failed to save metadata cache: {}", e)); - } else { - logger::debug(&format!( - "✓ Cached {} plugin(s) for '{}@{}'", - plugin_entries.len(), - package_name_full, - cache_version - )); - } - } - - plugin_entries - } - }; - - let mut total_plugins = plugin_entries.len(); - - if total_plugins == 0 { - logger::warn(&format!( - "No plugins found in package '{}'", - package_short_name - )); - return Ok(0); - } - - logger::info(&format!( - "Found {} plugin(s) in package '{}'", - total_plugins, package_short_name - )); - - // Register main package plugins with install_type: "explicit" - for (key, mut plugin) in plugin_entries { - plugin.install_type = Some("explicit".to_string()); - plugin.package_name = Some(package_name_full.to_string()); - let _ = manifest.add_plugin(key.clone(), plugin); - logger::debug(&format!("Registered: {}", key)); - } - - // Register r2x plugin dependencies (dependencies already fetched in parent function) - - if !dependencies.is_empty() { - let total_deps = dependencies.len(); - logger::debug(&format!( - "Found {} dependencies for '{}', checking for r2x plugins...", - total_deps, package - )); - - // Filter to only check r2x packages (pre-filter for performance) - let start = std::time::Instant::now(); - let r2x_dependencies: Vec = dependencies - .iter() - .filter(|dep| looks_like_r2x_plugin(dep)) - .cloned() - .collect(); - logger::debug(&format!( - "Filtering dependencies took: {:?}, result: {} r2x plugins from {} total", - start.elapsed(), - r2x_dependencies.len(), - total_deps - )); - - if r2x_dependencies.is_empty() { - logger::debug("No r2x plugin dependencies found"); - } else { - logger::debug(&format!( - "Processing {} r2x plugin(s)...", - r2x_dependencies.len() - )); - - let mut metadata_cache = PluginMetadataCache::load().unwrap_or_else(|e| { - logger::debug(&format!( - "Failed to load metadata cache for dependencies: {}", - e - )); - PluginMetadataCache::default() - }); - - for dep in r2x_dependencies { - let dep_short_name = dep.strip_prefix("r2x-").unwrap_or(&dep); - - let dep_start = std::time::Instant::now(); - - // Try metadata cache first (dependencies use "unknown" version), unless --no-cache - let dep_plugin_entries = if !no_cache - && metadata_cache.get_package(&dep, "unknown").is_some() - { - let cached_package = metadata_cache.get_package(&dep, "unknown").unwrap(); - logger::debug(&format!( - "✓ Cache hit: Found {} plugin(s) for '{}' in metadata cache", - cached_package.plugins.len(), - &dep - )); - - Ok(PluginMetadataCache::extract_plugins(cached_package)) - } else { - // Cache miss - load from bridge - logger::debug(&format!( - "✗ Cache miss: Loading '{}' from package metadata", - &dep - )); - let bridge = python_bridge::Bridge::get() - .map_err(|e| format!("Failed to initialize Python bridge: {}", e))?; - - match bridge.build_manifest_from_package(dep_short_name, &dep) { - Ok(dep_plugin_entries) => { - // Cache the dependency plugins (unless --no-cache) - if !no_cache && !dep_plugin_entries.is_empty() { - let mut dep_cached_package = CachedPackage::new(dep.clone()); - - for (name, plugin) in &dep_plugin_entries { - let cached_plugin = CachedPlugin { - name: name.clone(), - obj: plugin.obj.as_ref().map(|obj| crate::plugin_cache::CallableMetadata { - module: obj.module.clone(), - name: obj.name.clone(), - callable_type: obj.callable_type.clone(), - return_annotation: obj.return_annotation.clone(), - parameters: obj.parameters.iter().map(|(k, v)| { - (k.clone(), crate::plugin_cache::ParameterMetadata { - annotation: v.annotation.clone(), - default: v.default.as_ref().and_then(|d| { - serde_json::from_str::(d).ok().filter(|val| !val.is_null()) - }), - is_required: v.is_required, - }) - }).collect(), - }), - plugin_type: plugin.plugin_type.clone().unwrap_or_default(), - config: plugin.config.as_ref().and_then(|c| serde_json::to_value(c).ok()), - call_method: plugin.call_method.clone(), - }; - dep_cached_package.add_plugin(cached_plugin); - } - - if let Err(e) = metadata_cache.set_package( - dep.clone(), - "unknown".to_string(), - dep_cached_package, - ) { - logger::debug(&format!( - "Warning: Failed to cache dependency plugins for '{}': {}", - dep, e - )); - } else if let Err(e) = metadata_cache.save() { - logger::debug(&format!( - "Warning: Failed to save metadata cache after dependency load: {}", - e - )); - } else { - logger::debug(&format!( - "✓ Cached {} plugin(s) for '{}'", - dep_plugin_entries.len(), - dep - )); - } - } - Ok(dep_plugin_entries) - } - Err(e) => Err(e), - } - }; - - match dep_plugin_entries { - Ok(dep_plugin_entries) => { - logger::debug(&format!("Loading {} took: {:?}", dep, dep_start.elapsed())); - if !dep_plugin_entries.is_empty() { - logger::debug(&format!( - "Found {} r2x plugin(s) in dependency '{}'", - dep_plugin_entries.len(), - dep - )); - - for (key, mut plugin) in dep_plugin_entries { - plugin.install_type = Some("dependency".to_string()); - plugin.installed_by = Some(package_name_full.to_string()); - plugin.package_name = Some(dep.clone()); - let _ = manifest.add_plugin(key.clone(), plugin); - total_plugins += 1; - logger::debug(&format!("Registered (dependency): {}", key)); - } - } - } - Err(e) => { - logger::debug(&format!( - "Dependency '{}' failed to load as r2x plugin (took {:?}): {}", - dep, - dep_start.elapsed(), - e - )); - } - } - } - } - } - - // Save the updated manifest with all plugins (explicit + dependencies) - manifest - .save() - .map_err(|e| format!("Failed to save manifest: {}", e))?; - - Ok(total_plugins) -} - -/// Quick check: does the package have an entry_points.txt file? -/// This is a fast file system check to avoid Python bridge initialization -fn check_entry_points_exists(package_name_full: &str) -> bool { - // Get venv path - let config = match Config::load() { - Ok(c) => c, - Err(_) => return false, - }; - let venv_path = PathBuf::from(config.get_venv_path()); - - // Find site-packages directory - // On Windows: venv\Lib\site-packages - // On Unix: venv/lib/python3.x/site-packages - let site_packages_path = if cfg!(windows) { - venv_path.join("Lib").join("site-packages") - } else { - let site_packages = venv_path.join("lib"); - let entries = match fs::read_dir(&site_packages) { - Ok(e) => e, - Err(_) => return false, - }; - - let python_version_dir = match entries - .filter_map(|e| e.ok()) - .find(|e| e.file_name().to_string_lossy().starts_with("python")) - { - Some(d) => d, - None => return false, - }; - - python_version_dir.path().join("site-packages") - }; - - // Convert package name format: "r2x-reeds" -> "r2x_reeds" for dist-info lookup - let normalized_name = package_name_full.replace('-', "_"); - - // Find dist-info directory matching the package name - // Match exactly: package_name + "-" to avoid matching r2x_sienna when looking for r2x_sienna_to_plexos - if let Ok(entries) = fs::read_dir(&site_packages_path) { - for entry in entries.flatten() { - let file_name = entry.file_name().to_string_lossy().to_string(); - let expected_prefix = format!("{}-", normalized_name); - if file_name.starts_with(&expected_prefix) && file_name.ends_with(".dist-info") { - let entry_points_path = entry.path().join("entry_points.txt"); - return entry_points_path.exists(); - } - } - } - - false -} - -/// Check if a package name looks like it could be an r2x plugin package. -/// This is a fast pre-filter before attempting expensive Python bridge calls. -fn looks_like_r2x_plugin(package_name: &str) -> bool { - // Only check packages that start with "r2x-" but skip infrastructure packages - // that are never plugins - if !package_name.starts_with("r2x-") { - return false; - } - - // Skip known infrastructure/dependency packages - match package_name { - "r2x-core" => false, // Core infrastructure, not a plugin - "chronify" => false, // Time series dependency - "infrasys" => false, // Infrastructure systems dependency - "plexosdb" => false, // PLEXOS database dependency - _ => true, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_check_entry_points_exists() { - // Test entry points file detection - } - - #[test] - fn test_looks_like_r2x_plugin() { - assert!(looks_like_r2x_plugin("r2x-reeds")); - assert!(looks_like_r2x_plugin("r2x-plexos")); - assert!(!looks_like_r2x_plugin("r2x-core")); - assert!(!looks_like_r2x_plugin("chronify")); - assert!(!looks_like_r2x_plugin("requests")); - } - - #[test] - fn test_discover_and_register() { - // Test plugin discovery and registration - } -} diff --git a/src/plugins/list.rs b/src/plugins/list.rs deleted file mode 100644 index 6f1ff66..0000000 --- a/src/plugins/list.rs +++ /dev/null @@ -1,76 +0,0 @@ -use crate::plugin_manifest::PluginManifest; -use crate::GlobalOpts; -use colored::*; -use std::collections::BTreeMap; - -pub fn list_plugins(_opts: &GlobalOpts) -> Result<(), String> { - let manifest = PluginManifest::load().map_err(|e| format!("Failed to load manifest: {}", e))?; - if manifest.is_empty() { - println!("There are no current plugins installed."); - println!(); - println!("To install a plugin, run:"); - println!(" {} install ", "r2x".bold().cyan()); - return Ok(()); - } - - // Group plugins by package name and then by callable type (class/function) - let plugins = manifest.list_plugins(); - let mut packages: BTreeMap>> = BTreeMap::new(); - - for (name, plugin) in &plugins { - let package_name = plugin - .package_name - .clone() - .unwrap_or_else(|| "unknown".to_string()); - - let callable_type = if let Some(obj) = &plugin.obj { - obj.callable_type.clone() - } else { - plugin - .plugin_type - .clone() - .unwrap_or_else(|| "other".to_string()) - }; - - packages - .entry(package_name) - .or_default() - .entry(callable_type) - .or_default() - .push(name.to_string()); - } - - for (package_name, types) in &packages { - let total_plugins: usize = types.values().map(|v| v.len()).sum(); - println!( - " {} {}:", - package_name.bold().blue(), - format!("(total plugins: {})", total_plugins).dimmed() - ); - - for (type_name, plugin_names) in types { - println!(" {}:", type_name); - for plugin_name in plugin_names { - println!(" - {}", plugin_name); - } - } - println!(); - } - - println!("{}: {}", "Total installed packages".bold(), packages.len()); - - Ok(()) -} - -#[cfg(test)] -mod tests { - #[test] - fn test_list_empty_manifest() { - // Test handling of empty manifest - } - - #[test] - fn test_list_formatting() { - // Test plugin grouping and display - } -} diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs deleted file mode 100644 index 5f6551f..0000000 --- a/src/plugins/mod.rs +++ /dev/null @@ -1,22 +0,0 @@ -// Re-export public operations -pub mod clean; -pub mod discovery; -pub mod install; -pub mod list; -pub mod package_spec; -pub mod remove; - -// Re-export public functions from submodules -pub use clean::clean_manifest; -pub use install::{install_plugin, show_install_help, GitOptions}; -pub use list::list_plugins; -pub use remove::remove_plugin; - -#[cfg(test)] -mod tests { - - #[test] - fn test_plugins_module() { - // Module-level tests - } -} diff --git a/src/python_bridge/plugin_invoker.rs b/src/python_bridge/plugin_invoker.rs deleted file mode 100644 index 9df8aaf..0000000 --- a/src/python_bridge/plugin_invoker.rs +++ /dev/null @@ -1,431 +0,0 @@ -//! Plugin invocation and execution -//! -//! This module handles executing plugins with configuration and input data, -//! including argument building and special handling for different plugin types. - -use crate::errors::BridgeError; -use crate::logger; -use crate::plugin_manifest::Plugin; -use pyo3::prelude::*; -use pyo3::types::PyModule; - -impl super::Bridge { - /// Invoke a plugin with configuration - /// - /// Calls a plugin callable with the provided configuration and optional stdin data. - /// - /// # Arguments - /// * `target` - Entry point in format "module.path:callable_name" - /// * `config_json` - Configuration as JSON string - /// * `stdin_json` - Optional stdin data as JSON string - /// * `plugin_metadata` - Optional plugin metadata for smart argument handling - /// - /// # Returns - /// Plugin output as JSON string - /// - /// # Example - /// ```ignore - /// let output = bridge.invoke_plugin( - /// "r2x_reeds.plugins:parse_reeds", - /// r#"{"solve_year": 2030}"#, - /// None, - /// None - /// )?; - /// ``` - pub fn invoke_plugin( - &self, - target: &str, - config_json: &str, - stdin_json: Option<&str>, - plugin_metadata: Option<&Plugin>, - ) -> Result { - Python::with_gil(|py| { - // Parse target (module:callable or module:Class.method) - logger::debug(&format!("Parsing target: {}", target)); - let parts: Vec<&str> = target.split(':').collect(); - if parts.len() != 2 { - return Err(BridgeError::InvalidEntryPoint(target.to_string())); - } - let module_path = parts[0]; - let callable_path = parts[1]; - logger::debug(&format!( - "Module: {}, Callable: {}", - module_path, callable_path - )); - - // Import the module and JSON - logger::debug(&format!("Importing module: {}", module_path)); - let module = PyModule::import(py, module_path) - .map_err(|e| BridgeError::Import(module_path.to_string(), format!("{}", e)))?; - logger::debug("Module imported successfully"); - let json_module = PyModule::import(py, "json") - .map_err(|e| BridgeError::Import("json".to_string(), format!("{}", e)))?; - let loads = json_module.getattr("loads")?; - - // Parse config JSON and stdin JSON - logger::debug("Parsing config JSON"); - let config_dict = loads - .call1((config_json,))? - .downcast::() - .map_err(|e| BridgeError::Python(format!("Config must be a JSON object: {}", e)))? - .clone(); - logger::debug("Config parsed successfully"); - - let stdin_obj = if let Some(stdin) = stdin_json { - logger::debug("Parsing stdin JSON"); - Some(loads.call1((stdin,))?) - } else { - None - }; - - // Build kwargs by processing each parameter based on metadata - logger::debug("Building kwargs for plugin invocation"); - let kwargs = - self.build_kwargs(py, &config_dict, stdin_obj.as_ref(), plugin_metadata)?; - logger::debug("Kwargs built successfully"); - - // Invoke the plugin - logger::debug("Starting plugin invocation"); - let result_py = if callable_path.contains('.') { - // Class.method pattern - let parts: Vec<&str> = callable_path.split('.').collect(); - if parts.len() != 2 { - return Err(BridgeError::InvalidEntryPoint(target.to_string())); - } - let (class_name, method_name) = (parts[0], parts[1]); - logger::debug(&format!("Class pattern: {}.{}", class_name, method_name)); - - logger::debug(&format!("Getting class: {}", class_name)); - let class = module.getattr(class_name).map_err(|e| { - BridgeError::Python(format!("Failed to get class '{}': {}", class_name, e)) - })?; - - logger::debug(&format!("Instantiating class: {}", class_name)); - let instance = class.call((), Some(&kwargs)).map_err(|e| { - BridgeError::Python(format!("Failed to instantiate '{}': {}", class_name, e)) - })?; - logger::debug("Class instantiated successfully"); - - logger::debug(&format!("Getting method: {}", method_name)); - let method = instance.getattr(method_name).map_err(|e| { - BridgeError::Python(format!("Failed to get method '{}': {}", method_name, e)) - })?; - - // Call method (stdin passed to method for sysmods, not constructor) - logger::debug(&format!("Calling method: {}", method_name)); - if let Some(stdin) = stdin_obj { - method.call1((stdin,)).map_err(|e| { - BridgeError::Python(format!( - "Method '{}.{}' failed: {}", - class_name, method_name, e - )) - })? - } else { - method.call0().map_err(|e| { - BridgeError::Python(format!( - "Method '{}.{}' failed: {}", - class_name, method_name, e - )) - })? - } - } else { - // Function pattern - logger::debug(&format!("Function pattern: {}", callable_path)); - logger::debug(&format!("Getting function: {}", callable_path)); - let func = module.getattr(callable_path).map_err(|e| { - BridgeError::Python(format!( - "Failed to get function '{}': {}", - callable_path, e - )) - })?; - - // For functions, pass kwargs (system comes from stdin) - logger::debug("Calling function with kwargs"); - logger::step(&format!("Function kwargs before system: {:?}", kwargs)); - if let Some(stdin) = stdin_obj { - logger::step("Function has stdin - deserializing to System object"); - // Deserialize stdin JSON to System object for sysmods - logger::debug("Deserializing stdin JSON to System object"); - - // Convert stdin dict back to JSON bytes - let dumps = json_module.getattr("dumps")?; - let json_str = dumps.call1((stdin,))?.extract::()?; - let json_bytes = json_str.as_bytes(); - - // Import System and call from_json - let system_module = PyModule::import(py, "r2x_core.system")?; - let system_class = system_module.getattr("System")?; - let from_json = system_class.getattr("from_json")?; - let system_obj = from_json.call1((json_bytes,))?; - - logger::debug("System object deserialized successfully"); - kwargs.set_item("system", system_obj)?; - } else { - logger::debug("Function has no stdin"); - } - - logger::step(&format!("Final function kwargs: {:?}", kwargs)); - func.call((), Some(&kwargs)).map_err(|e| { - BridgeError::Python(format!("Function '{}' failed: {}", callable_path, e)) - })? - }; - logger::debug("Plugin execution completed"); - - // Serialize result to JSON - logger::debug("Serializing result to JSON"); - - // Check if result has a to_json() method (e.g., System objects) - if result_py.hasattr("to_json")? { - // Call to_json() with no arguments (fname=None) which returns bytes - let to_json_result = result_py.call_method0("to_json")?; - - // Check if the result is bytes (Python bytes object) - if let Ok(json_bytes) = to_json_result.extract::>() { - let json_str = String::from_utf8(json_bytes).map_err(|e| { - BridgeError::Python(format!("Invalid UTF-8 in JSON output: {}", e)) - })?; - Ok(json_str) - } else { - // to_json() returned None or something else - fall back to json.dumps - let dumps = json_module.getattr("dumps")?; - let json_str = dumps.call1((result_py,))?.extract::()?; - Ok(json_str) - } - } else { - // Use standard JSON serialization - let dumps = json_module.getattr("dumps")?; - let json_str = dumps.call1((result_py,))?.extract::()?; - Ok(json_str) - } - }) - } - - /// Build kwargs for plugin invocation based on parameter metadata - fn build_kwargs<'py>( - &self, - py: Python<'py>, - config_dict: &pyo3::Bound<'py, pyo3::types::PyDict>, - stdin_obj: Option<&pyo3::Bound<'py, PyAny>>, - plugin_metadata: Option<&Plugin>, - ) -> Result, BridgeError> { - let kwargs = pyo3::types::PyDict::new(py); - - // Get plugin obj metadata - let obj = match plugin_metadata.and_then(|m| m.obj.as_ref()) { - Some(o) => o, - None => { - // No metadata, pass config as-is - for (k, v) in config_dict { - kwargs.set_item(k, v)?; - } - return Ok(kwargs); - } - }; - - // Check if we need to instantiate a config class - let mut needs_config_class = false; - let mut config_param_name = String::new(); - for (param_name, param_meta) in &obj.parameters { - let annotation = param_meta.annotation.as_deref().unwrap_or(""); - if param_name == "config" || annotation.contains("Config") { - needs_config_class = true; - config_param_name = param_name.clone(); - break; - } - } - - // First pass: collect parameters for config class and handle special parameters - let mut config_instance: Option> = None; - if needs_config_class && plugin_metadata.is_some() { - // Check if config is already provided as a nested dict (pipeline mode) - // or if we need to collect it from top-level params (direct plugin mode) - let config_params = if let Ok(Some(existing_config)) = config_dict.get_item("config") { - // Pipeline mode: config is already nested - if let Ok(config_dict_value) = existing_config.downcast::() { - config_dict_value.clone() - } else { - // config exists but isn't a dict - create empty and let Python validate - pyo3::types::PyDict::new(py) - } - } else { - // Direct plugin mode: collect all config dict items that aren't special parameters - let params = pyo3::types::PyDict::new(py); - for (key, value) in config_dict.iter() { - let key_str = key.extract::()?; - // Skip special parameters that aren't config parameters - if key_str != "data_store" && key_str != "store_path" { - params.set_item(key, value)?; - } - } - params - }; - - // Instantiate config class with collected parameters - config_instance = - Some(self.instantiate_config_class(py, &config_params, plugin_metadata)?); - kwargs.set_item(&config_param_name, config_instance.as_ref().unwrap())?; - } - - // Second pass: process special parameters - for (param_name, param_meta) in &obj.parameters { - let annotation = param_meta.annotation.as_deref().unwrap_or(""); - - // Skip config - already processed - if param_name == "config" || annotation.contains("Config") { - continue; - } - - // Handle data_store parameter - can come from "data_store" or "store_path" - // Handle data_store parameter - can come from "store_path" first (user-friendly name), then "data_store" - if param_name == "data_store" || annotation.contains("DataStore") { - logger::step(&format!("Processing data_store parameter: {}", param_name)); - // Try to get from "store_path" first (user-friendly name), then "data_store" - let value = config_dict - .get_item("store_path")? - .or_else(|| config_dict.get_item(param_name).ok().flatten()); - - if let Some(value) = value { - logger::step(&format!("Found data_store value, instantiating DataStore")); - let store_instance = - self.instantiate_data_store(py, &value, config_instance.as_ref())?; - kwargs.set_item(param_name, store_instance)?; - } else if param_meta.is_required { - return Err(BridgeError::Python(format!( - "Required parameter '{}' not provided (you can use 'store_path' or 'data_store')", - param_name - ))); - } - continue; - } - - // Handle system parameter from stdin - if param_name == "system" && stdin_obj.is_some() { - // System parameter comes from stdin (for sysmods) - // Don't add to kwargs - it's passed to the method call, not constructor - continue; - } - - // Handle other parameters directly from config_dict - if let Some(value) = config_dict.get_item(param_name)? { - logger::step(&format!( - "Adding parameter '{}' to kwargs directly", - param_name - )); - kwargs.set_item(param_name, value)?; - } else if !param_meta.is_required { - // Optional parameter not provided - skip it - continue; - } - // Required parameter not provided - let Python raise the error - } - - logger::step(&format!("Final built kwargs (keys): {:?}", kwargs.keys())); - Ok(kwargs) - } - - /// Instantiate config class from dict - fn instantiate_config_class<'py>( - &self, - py: Python<'py>, - config_params: &pyo3::Bound<'py, pyo3::types::PyDict>, - plugin_metadata: Option<&Plugin>, - ) -> Result, BridgeError> { - // Get config metadata - let config_meta = match plugin_metadata.and_then(|m| m.config.as_ref()) { - Some(c) => c, - None => { - return Err(BridgeError::Python( - "No config metadata available".to_string(), - )) - } - }; - - // Import config module and get class - let config_module = PyModule::import(py, config_meta.module.as_str()) - .map_err(|e| BridgeError::Import(config_meta.module.clone(), format!("{}", e)))?; - - let config_class = config_module - .getattr(config_meta.name.as_str()) - .map_err(|e| { - BridgeError::Python(format!( - "Failed to get config class '{}': {}", - config_meta.name, e - )) - })?; - - // Instantiate with dict as kwargs - config_class.call((), Some(config_params)).map_err(|e| { - BridgeError::Python(format!( - "Failed to instantiate config class '{}.{}': {}", - config_meta.module, config_meta.name, e - )) - }) - } - - /// Instantiate DataStore from path string or dict, optionally using config for file mappings - fn instantiate_data_store<'py>( - &self, - py: Python<'py>, - value: &pyo3::Bound<'py, PyAny>, - config_instance: Option<&pyo3::Bound<'py, PyAny>>, - ) -> Result, BridgeError> { - // Import DataStore from r2x_core.store - let store_module = PyModule::import(py, "r2x_core.store") - .map_err(|e| BridgeError::Import("r2x_core.store".to_string(), format!("{}", e)))?; - - let datastore_class = store_module.getattr("DataStore")?; - - // Check if value is a string path (most common case) - if let Ok(path_str) = value.extract::() { - // If we have a config instance, use from_plugin_config to load file mappings - if let Some(config) = config_instance { - let from_plugin_config = datastore_class.getattr("from_plugin_config")?; - from_plugin_config.call1((config, path_str)).map_err(|e| { - BridgeError::Python(format!( - "Failed to create DataStore from plugin config: {}", - e - )) - }) - } else { - // No config - create basic DataStore with just path - datastore_class.call1((path_str,)).map_err(|e| { - BridgeError::Python(format!("Failed to instantiate DataStore: {}", e)) - }) - } - } else if let Ok(store_dict) = value.downcast::() { - // Handle dict format for backward compatibility - let path = store_dict.get_item("path")?.ok_or_else(|| { - BridgeError::Python("DataStore dict missing 'path' field".to_string()) - })?; - - // If we have a config instance, use from_plugin_config to load file mappings - if let Some(config) = config_instance { - let from_plugin_config = datastore_class.getattr("from_plugin_config")?; - from_plugin_config.call1((config, path)).map_err(|e| { - BridgeError::Python(format!( - "Failed to create DataStore from plugin config: {}", - e - )) - }) - } else { - // No config - create basic DataStore with just path - datastore_class.call1((path,)).map_err(|e| { - BridgeError::Python(format!("Failed to instantiate DataStore: {}", e)) - }) - } - } else { - Err(BridgeError::Python( - "DataStore value must be a string path or dict with 'path' field".to_string(), - )) - } - } -} - -#[cfg(test)] -mod tests { - #[test] - fn test_plugin_invocation_placeholder() { - // Plugin invocation tests would require actual plugins - // This is a placeholder for integration testing - } -} diff --git a/tests/integration.rs b/tests/integration.rs deleted file mode 100644 index c53fa9c..0000000 --- a/tests/integration.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Integration tests for r2x - -use assert_cmd::Command; -use predicates::prelude::*; - -#[test] -fn test_version() { - Command::cargo_bin("r2x") - .unwrap() - .arg("--version") - .assert() - .success() - .stdout(predicate::str::contains("r2x")); -} - -#[test] -fn test_help() { - Command::cargo_bin("r2x") - .unwrap() - .arg("--help") - .assert() - .success() - .stdout(predicate::str::contains("Energy model data converter")); -} - -#[test] -fn test_list_plugins_no_plugins() { - Command::cargo_bin("r2x") - .unwrap() - .args(["plugin", "list"]) - .assert() - .success(); -} - -#[test] -fn test_invalid_command() { - Command::cargo_bin("r2x") - .unwrap() - .arg("invalid") - .assert() - .failure(); -} - -#[test] -fn test_plugins_help() { - Command::cargo_bin("r2x") - .unwrap() - .args(["plugin", "--help"]) - .assert() - .success() - .stdout(predicate::str::contains("Manage plugins")); -} - -#[test] -fn test_config_show() { - Command::cargo_bin("r2x") - .unwrap() - .args(["config", "show"]) - .assert() - .success() - .stdout(predicate::str::contains("[python]")); -} - -#[test] -fn test_config_get() { - Command::cargo_bin("r2x") - .unwrap() - .args(["config", "get", "python.version"]) - .assert() - .success() - .stdout(predicate::str::contains("3.11")); -} diff --git a/tests/tests/test_plugin_package_integration.rs b/tests/tests/test_plugin_package_integration.rs deleted file mode 100644 index 03dcdeb..0000000 --- a/tests/tests/test_plugin_package_integration.rs +++ /dev/null @@ -1,239 +0,0 @@ -//! Integration test for Plugin Package loading via Rust bridge -//! -//! Tests the new Package-based plugin registration system: -//! 1. Bridge initialization -//! 2. Loading plugin package from r2x_plugin entry point -//! 3. JSON deserialization into structs -//! 4. Verification of plugin metadata - -use r2x::python_bridge::Bridge; - -#[test] -fn test_bridge_initialization() { - // Test that bridge can be initialized - let bridge = Bridge::get(); - assert!(bridge.is_ok(), "Bridge should initialize successfully"); -} - -#[test] -fn test_load_reeds_plugin_package() { - let bridge = Bridge::get().expect("Bridge should initialize"); - - // Test loading r2x-reeds plugin package - let result = bridge.load_plugin_package("reeds"); - assert!( - result.is_ok(), - "Should successfully load reeds plugin package" - ); - - let json_str = result.unwrap(); - assert!(!json_str.is_empty(), "Package JSON should not be empty"); - assert!( - json_str.len() > 100, - "Package JSON should contain substantial data" - ); -} - -#[test] -fn test_reeds_package_json_structure() { - let bridge = Bridge::get().expect("Bridge should initialize"); - let json_str = bridge - .load_plugin_package("reeds") - .expect("Should load package"); - - // Parse JSON - let value: serde_json::Value = serde_json::from_str(&json_str).expect("JSON should be valid"); - - // Verify structure - assert_eq!(value["name"], "reeds", "Package name should be 'reeds'"); - - let plugins = value["plugins"] - .as_array() - .expect("Should have plugins array"); - assert!( - !plugins.is_empty(), - "Package should contain at least one plugin" - ); - - // Check first plugin (should be ReEDSParser) - let first = &plugins[0]; - assert_eq!( - first["name"], "reeds", - "First plugin should be named 'reeds'" - ); - assert_eq!( - first["plugin_type"], "class", - "Parser should be of type 'class'" - ); - - // Check callable metadata - let obj = &first["obj"]; - assert_eq!( - obj["module"], "r2x_reeds.parser", - "Module should be r2x_reeds.parser" - ); - assert_eq!( - obj["name"], "ReEDSParser", - "Callable name should be ReEDSParser" - ); - assert_eq!(obj["type"], "class", "Should be a class"); - - // Check parameters exist - assert!( - obj["parameters"].is_object(), - "Should have parameters object" - ); - - // Check config class exists - assert!( - first["config"].is_object(), - "Parser should have config metadata" - ); - assert_eq!( - first["config"]["name"], "ReEDSConfig", - "Config should be ReEDSConfig" - ); -} - -#[test] -fn test_reeds_package_contains_multiple_plugins() { - let bridge = Bridge::get().expect("Bridge should initialize"); - let result = bridge.load_plugin_package("reeds"); - if let Err(ref e) = result { - println!( - "Error in test_reeds_package_contains_multiple_plugins: {}", - e - ); - } - let json_str = result.expect("Should load package"); - - let value: serde_json::Value = serde_json::from_str(&json_str).expect("JSON should be valid"); - - // Should have parser, upgrader, and several sysmod plugins - let plugins = value["plugins"].as_array().expect("Should have plugins"); - assert!( - plugins.len() > 3, - "Package should have multiple plugins (parser, upgrader, sysmods), found {}", - plugins.len() - ); - - // Collect plugin types - let types: Vec<_> = plugins - .iter() - .filter_map(|p| p["plugin_type"].as_str()) - .collect(); - - // Should have both class (parser/upgrader) and function (sysmods) plugins - assert!( - types.contains(&"class"), - "Should have at least one class-type plugin" - ); - assert!( - types.contains(&"function"), - "Should have at least one function-type plugin (sysmod)" - ); -} - -#[test] -fn test_reeds_sysmod_plugins() { - let bridge = Bridge::get().expect("Bridge should initialize"); - let result = bridge.load_plugin_package("reeds"); - if let Err(ref e) = result { - println!("Error in test_reeds_sysmod_plugins: {}", e); - } - let json_str = result.expect("Should load package"); - - let value: serde_json::Value = serde_json::from_str(&json_str).expect("JSON should be valid"); - - let plugins = value["plugins"].as_array().expect("Should have plugins"); - - // Find function-type plugins (sysmods) - let sysmods: Vec<_> = plugins - .iter() - .filter(|p| p["plugin_type"] == "function") - .collect(); - - assert!( - !sysmods.is_empty(), - "Should have at least one sysmod plugin" - ); - - // Check first sysmod - let sysmod = sysmods[0]; - let obj = &sysmod["obj"]; - - assert!(obj["module"].is_string(), "Sysmod should have module"); - assert!(obj["name"].is_string(), "Sysmod should have name"); - assert_eq!(obj["type"], "function", "Sysmod should be function type"); -} - -#[test] -fn test_package_metadata() { - let bridge = Bridge::get().expect("Bridge should initialize"); - let result = bridge.load_plugin_package("reeds"); - if let Err(ref e) = result { - println!("Error in test_package_metadata: {}", e); - } - let json_str = result.expect("Should load package"); - - let value: serde_json::Value = serde_json::from_str(&json_str).expect("JSON should be valid"); - - // Check metadata exists - assert!( - value["metadata"].is_object(), - "Package should have metadata" - ); - assert!( - value["metadata"]["version"].is_string(), - "Should have version in metadata" - ); -} - -#[test] -fn test_callable_has_parameters() { - let bridge = Bridge::get().expect("Bridge should initialize"); - let result = bridge.load_plugin_package("reeds"); - if let Err(ref e) = result { - println!("Error in test_callable_has_parameters: {}", e); - } - let json_str = result.expect("Should load package"); - - let value: serde_json::Value = serde_json::from_str(&json_str).expect("JSON should be valid"); - - // Check first plugin's callable - let obj = &value["plugins"][0]["obj"]; - let params = &obj["parameters"]; - - assert!(params.is_object(), "Callable should have parameters object"); - assert!( - !params.as_object().unwrap().is_empty(), - "Should have at least one parameter" - ); - - // Check parameter structure - for (param_name, param_info) in params.as_object().unwrap() { - assert!( - param_info["is_required"].is_boolean(), - "Parameter {} should have is_required flag", - param_name - ); - assert!( - param_info.get("annotation").is_some(), - "Parameter {} should have annotation", - param_name - ); - } -} - -#[test] -fn test_nonexistent_package() { - let bridge = Bridge::get().expect("Bridge should initialize"); - - // Try to load non-existent package - let result = bridge.load_plugin_package("nonexistent_package_xyz"); - - assert!( - result.is_err(), - "Should fail when loading non-existent package" - ); -} diff --git a/tests/unit.rs b/tests/unit.rs deleted file mode 100644 index e840eb8..0000000 --- a/tests/unit.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Unit tests for r2x modules - -#[test] -fn test_plugin_registry_is_empty() { - use std::collections::HashMap; - let registry = r2x::python::plugin::PluginRegistry { - parsers: HashMap::new(), - exporters: HashMap::new(), - modifiers: HashMap::new(), - filters: HashMap::new(), - }; - - assert!(registry.is_empty()); -} - -#[test] -fn test_venv_path_helpers() { - let venv_path = r2x::python::venv::get_venv_path(); - assert!(venv_path.is_ok()); - - let path = venv_path.unwrap(); - assert!(path.to_string_lossy().contains("r2x")); - assert!(path.to_string_lossy().contains("venv")); -}