diff --git a/.github/labeler.yml b/.github/labeler.yml index 2ed28a5..3485170 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,62 +1,35 @@ -# Auto-label PRs by changed files -'feat': - - src/**/*.rs - - Cargo.toml - -'fix': - - src/**/*.rs - -'docs': - - '**/*.md' - - '**/docs/**' - -'ci': - - .github/workflows/**/* - - .github/**/* - -'config': - - 'opencode.json' - - 'opencode.jsonc' - - '**/opencode*' - -'test': - - tests/**/* - -size/XS: - - all: - - '**/*' - any: - - count: '**/*.rs' - min: 1 - max: 3 - -size/S: - - all: - - '**/*' - any: - - count: '**/*.rs' - min: 4 - max: 10 - -size/M: - - all: - - '**/*' - any: - - count: '**/*.rs' - min: 11 - max: 30 - -size/L: - - all: - - '**/*' - any: - - count: '**/*.rs' - min: 31 - max: 100 - -size/XL: - - all: - - '**/*' - any: - - count: '**/*.rs' - min: 101 +# Auto-label PRs by changed files (actions/labeler v5 syntax) +feat: +- changed-files: + - any-glob-to-any-file: + - src/**/*.rs + - Cargo.toml + +fix: +- changed-files: + - any-glob-to-any-file: + - src/**/*.rs + +docs: +- changed-files: + - any-glob-to-any-file: + - '**/*.md' + - '**/docs/**' + +ci: +- changed-files: + - any-glob-to-any-file: + - .github/workflows/**/* + - .github/**/* + +config: +- changed-files: + - any-glob-to-any-file: + - 'opencode.json' + - 'opencode.jsonc' + - '**/opencode*' + +test: +- changed-files: + - any-glob-to-any-file: + - tests/**/* \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fff9e50..9d7c431 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: env: CARGO_TERM_COLOR: always + RUST_BACKTRACE: full jobs: test: @@ -20,7 +21,19 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - run: cargo test + + - name: Compile check + run: cargo check 2>&1 + + - name: Run tests (verbose) + run: cargo test -- --nocapture --test-threads=1 2>&1 + + - name: Test summary + if: always() + run: | + echo "### Test Results (${{ matrix.os }})" >> $GITHUB_STEP_SUMMARY + echo "- OS: ${{ matrix.os }}" >> $GITHUB_STEP_SUMMARY + echo "- Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY fmt: name: Format @@ -41,8 +54,7 @@ jobs: with: components: clippy - uses: Swatinem/rust-cache@v2 - - run: cargo clippy --all-targets - continue-on-error: true + - run: cargo clippy --all-targets -- -D warnings msrv: name: MSRV (1.86.0) diff --git a/Cargo.lock b/Cargo.lock index d5c196c..7fdc93d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,9 +22,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "async-trait" @@ -49,6 +49,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.22.1" @@ -69,17 +92,19 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.64" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -95,6 +120,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -109,12 +145,50 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "dirs" version = "5.0.1" @@ -147,6 +221,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "equivalent" version = "1.0.2" @@ -163,6 +243,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastbloom" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" +dependencies = [ + "foldhash", + "libm", + "portable-atomic", + "siphasher", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -177,9 +269,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "foldhash" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "form_urlencoded" @@ -190,6 +282,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -254,52 +352,24 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.4" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 5.3.0", - "wasip2", + "r-efi", + "rand_core", "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - [[package]] name = "http" version = "1.4.2" @@ -312,9 +382,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -322,9 +392,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -504,12 +574,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "idna" version = "1.1.0" @@ -538,9 +602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", + "hashbrown", ] [[package]] @@ -555,11 +617,70 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" -version = "0.3.102" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", @@ -572,23 +693,23 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -605,20 +726,11 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru-slab" @@ -637,15 +749,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -676,6 +788,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -684,50 +802,29 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "oura" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "async-trait", - "base64", "chrono", "dirs", + "quinn", "regex", "reqwest", + "rustls", + "rustls-pemfile", "serde", "serde_json", - "serde_yaml", "tempfile", "thiserror 1.0.69", "tokio", "toml", "tracing", "tracing-subscriber", + "url", "uuid", ] -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -741,31 +838,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "potential_utf" -version = "0.1.5" +name = "pkg-config" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "portable-atomic" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] -name = "prettyplease" -version = "0.2.37" +name = "potential_utf" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ - "proc-macro2", - "syn", + "zerovec", ] [[package]] @@ -779,9 +869,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -799,18 +889,21 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "bytes", - "getrandom 0.3.4", + "fastbloom", + "getrandom 0.4.3", "lru-slab", "rand", + "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "slab", "thiserror 2.0.18", "tinyvec", @@ -820,33 +913,27 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -855,40 +942,28 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "ppv-lite86", + "chacha20", + "getrandom 0.4.3", "rand_core", ] [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "bitflags", + "rand_core", ] [[package]] @@ -904,9 +979,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.4" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -916,9 +991,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -987,9 +1062,18 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] [[package]] name = "rustix" @@ -1006,10 +1090,12 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -1018,22 +1104,71 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1041,9 +1176,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -1052,10 +1187,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] -name = "scopeguard" -version = "1.2.0" +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] [[package]] name = "semver" @@ -1127,19 +1297,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -1165,6 +1322,28 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -1179,9 +1358,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -1201,9 +1380,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -1237,7 +1416,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1285,9 +1464,9 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -1304,9 +1483,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -1326,7 +1505,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -1447,6 +1625,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -1514,18 +1693,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -1552,11 +1719,11 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.3" +version = "1.23.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -1567,6 +1734,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[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 = "want" version = "0.3.1" @@ -1582,29 +1759,11 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - [[package]] name = "wasm-bindgen" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -1615,9 +1774,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.75" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -1625,9 +1784,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1635,9 +1794,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -1648,52 +1807,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.102" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -1709,15 +1834,33 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] +[[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 = "windows-core" version = "0.62.2" @@ -1795,15 +1938,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -1837,30 +1971,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -1873,12 +1990,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -1891,12 +2002,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -1909,24 +2014,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -1939,12 +2032,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -1957,12 +2044,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -1975,12 +2056,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -1993,12 +2068,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.15" @@ -2008,100 +2077,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -2131,26 +2106,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" version = "0.1.8" @@ -2213,6 +2168,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 0423131..6459479 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,26 +1,29 @@ [package] name = "oura" -version = "0.2.0" +version = "0.3.0" description = "Oura — MCP server for intelligent iterative looping. The serpent that refines code through endless improvement cycles." +rust-version = "1.86" edition = "2021" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -serde_yaml = "0.9" toml = "0.8" -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros", "process", "io-util", "io-std", "net"] } uuid = { version = "1.0", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } anyhow = "1.0" thiserror = "1.0" reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false } +quinn = "0.11" +rustls = "0.23" +rustls-pemfile = "2" regex = "1" dirs = "5.0" -base64 = "0.22" async-trait = "0.1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2" [dev-dependencies] tempfile = "3.0" diff --git a/README.md b/README.md index 0d2a4ea..2a9d451 100644 --- a/README.md +++ b/README.md @@ -12,20 +12,15 @@

Version - Rust - License - Tests - Clippy + Rust + License

## Características -- **Loop Engine**: iteraciones automáticas con detección de convergencia +- **Loop Engine**: iteraciones automáticas o manuales con detección de convergencia - **Feedback multi-fuente**: tests, lint, typecheck, custom - **Sub-agentes**: Security Auditor, Refactor Engine, Anti-deletion Guard, Code Optimizer -- **Integración GitHub**: PRs, workflows, actions, auto-commit, multi-repo -- **Plugin system**: hooks extensibles para eventos del loop -- **Synapsis bridge**: persistencia en Synapsis memory + task orchestration - **Config**: TOML + env vars (`OURA_*`) ## Arquitectura @@ -34,7 +29,7 @@ Oura Architecture

-Oura está compuesto por **14 módulos** organizados en capas: +Oura está compuesto por **13 módulos** organizados en capas: | Capa | Módulos | Responsabilidad | |------|---------|-----------------| @@ -63,6 +58,11 @@ export OURA_GITHUB_TOKEN=ghp_xxx export OURA_GITHUB_OWNER=MethodWhite export OURA_GITHUB_REPO=my-project export OURA_MAX_ITERATIONS=50 +export OURA_CONVERGENCE_THRESHOLD=90.0 +export OURA_GITHUB_ENABLED=true +export OURA_WORKING_DIR=/path/to/project +export OURA_CONFIG=/custom/path/config.toml +export OURA_QUIET=1 ``` ### Config file (`~/.config/oura/config.toml`) @@ -74,15 +74,18 @@ convergence_threshold = 90.0 feedback_sources = ["test", "lint"] [github] -enabled = true +enabled = false default_owner = "MethodWhite" default_repo = "my-project" -auto_commit = true -auto_pr = true - -[synapsis] -enabled = true -endpoint = "http://localhost:7438" +auto_commit = false +auto_pr = false + +# External MCP connector — call tools on other MCP servers during iteration +# [connector] +# enabled = true +# server_url = "http://localhost:7438" +# tools = ["synapsis_mem_search"] +# auto_call = true ``` ## Uso con MCP @@ -104,13 +107,14 @@ Añade a `opencode.json` / `claude-code.json` / `cursor.json`: | Tool | Descripción | |------|-------------| -| `oura_start_loop` | Inicia loop de iteración con goal | +| `oura_start_loop` | Inicia loop de iteración con goal (manual=true para modo manual con oura_iterate) | | `oura_iterate` | Ejecuta un paso manual | | `oura_loop_status` | Estado del loop actual | | `oura_loop_stop` | Detiene el loop | | `oura_results` | Resultados acumulados | | `oura_configure` | Actualiza configuración (maxIterations, threshold, workingDirectory) | -| `oura_working_dir` | Cambia el directorio de trabajo | +| `oura_working_dir` | Set the working directory for Oura commands | +| `oura_connector` | Call a tool on an external MCP server (e.g. Synapsis). Manual or auto via `[connector]` config | | `oura_plugin_load` | Carga un plugin | | `oura_plugin_list` | Lista plugins cargados | | `oura_analyze_security` | Auditoría de seguridad (9 patrones) | @@ -123,25 +127,25 @@ Añade a `opencode.json` / `claude-code.json` / `cursor.json`: | `oura_update` | Actualiza Oura desde git + cargo build | | `oura_profile` | Detecta perfil del proyecto | | `oura_verify` | Verifica licencias y dependencias | -| `mcp_call` | Llama a tools de otros servidores MCP | +| `mcp_call` | HTTP call to another MCP server (low-level; SSRF protected) | + ## Calidad | Métrica | Valor | |---------|-------| | Tests | 58 pasando | -| Cobertura clippy | 0 warnings | +| Clippy | 0 warnings | | `unwrap()` en producción | 0 | -| Deuda técnica corregida | ~55 issues | -| Lenguaje | Rust 1.86+ | -| Licencia | MIT | +| Lenguaje | Rust | +| Licencia | BSL 1.1 | ## Licencia -MIT — ver [LICENSE](LICENSE). +BSL 1.1 — ver [LICENSE](LICENSE). ---

- Hecho con 🐍 por MethodWhite + Hecho con 🦀 por MethodWhite

diff --git a/build.rs b/build.rs index c656aa9..4c118b2 100644 --- a/build.rs +++ b/build.rs @@ -18,4 +18,9 @@ fn main() { println!("cargo:rustc-env=GIT_HEAD={}", git_head); println!("cargo:rerun-if-changed=.git/HEAD"); + if let Ok(head) = std::fs::read_to_string(".git/HEAD") { + if let Some(ref_path) = head.strip_prefix("ref: ") { + println!("cargo:rerun-if-changed=.git/{}", ref_path.trim()); + } + } } diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..ca73efa --- /dev/null +++ b/install.ps1 @@ -0,0 +1,114 @@ +#!/usr/bin/env pwsh +#requires -Version 5.1 + +param( + [string]$Version = "latest", + [string]$InstallDir = "${env:ProgramFiles}\Oura", + [string]$ConfigDir = "${env:USERPROFILE}\.config\oura" +) + +$Repo = "MethodWhite/oura" +$Green = "Green" +$Yellow = "Yellow" +$Red = "Red" +$Cyan = "Cyan" + +function Log { Write-Host "[✓] $args" -ForegroundColor $Green } +function Warn { Write-Host "[!] $args" -ForegroundColor $Yellow } +function Err { Write-Host "[✗] $args" -ForegroundColor $Red; exit 1 } +function Info { Write-Host "[i] $args" -ForegroundColor $Cyan } + +function Detect-Arch { + $arch = if ([Environment]::Is64BitOperatingSystem) { "x86_64" } else { "i686" } + $env = [Environment]::GetEnvironmentVariable("PROCESSOR_IDENTIFIER") + if ($env -match "ARM|AArch64") { $arch = "aarch64" } + return $arch +} + +function Get-ReleaseUrl { + param([string]$OsArch) + if ($Version -eq "latest") { + return "https://github.com/$Repo/releases/latest/download/oura-${OsArch}.exe" + } + return "https://github.com/$Repo/releases/download/$Version/oura-${OsArch}.exe" +} + +function Main { + Write-Host "" + Write-Host "╔══════════════════════════════════════╗" -ForegroundColor $Cyan + Write-Host "║ Oura Installer — 0XFFRice ║" -ForegroundColor $Cyan + Write-Host "╚══════════════════════════════════════╝" -ForegroundColor $Cyan + Write-Host "" + + $elevated = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + if (-not $elevated) { + Warn "Not running as Administrator. Some installs may fail." + Warn "Restart with: Start-Process pwsh -Verb RunAs -ArgumentList '-File install.ps1'" + } + + $arch = Detect-Arch + $osArch = "${arch}-pc-windows-msvc" + $url = Get-ReleaseUrl -OsArch $osArch + + Info "Detected: $osArch" + Info "Download: $url" + + $tmpdir = "$env:TEMP\oura-install" + New-Item -ItemType Directory -Force -Path $tmpdir | Out-Null + $outFile = "$tmpdir\oura.exe" + + try { + Invoke-WebRequest -Uri $url -OutFile $outFile -UseBasicParsing -ErrorAction Stop + } catch { + Err "Download failed: $_" + } + + try { + $version = & $outFile --version 2>&1 + if (-not $?) { Err "Binary validation failed" } + } catch { + Err "Binary validation failed: $_" + } + + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + Copy-Item $outFile "$InstallDir\oura.exe" -Force + Log "Installed: $InstallDir\oura.exe" + + $userPath = [Environment]::GetEnvironmentVariable("PATH", "User") + if ($userPath -notlike "*$InstallDir*") { + [Environment]::SetEnvironmentVariable("PATH", "$InstallDir;$userPath", "User") + Log "Added to PATH (user scope)" + } + + New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null + $configFile = "$ConfigDir\config.toml" + if (-not (Test-Path $configFile)) { + @" +[loop_engine] +max_iterations = 20 +convergence_threshold = 90.0 +feedback_sources = ["test", "lint"] + +[github] +enabled = true +default_owner = "MethodWhite" +default_repo = "my-project" +auto_commit = true +auto_pr = true + +# [synapsis] # Optional: uncomment for Synapsis integration (separate project) +# enabled = true +# endpoint = "http://localhost:7438" +"@ | Out-File -FilePath $configFile -Encoding utf8 + Log "Config created: $configFile" + } else { + Warn "Config exists at $configFile — skipping" + } + + "" + Log "Oura installed successfully!" + & "$InstallDir\oura.exe" version 2>$null + "" +} + +Main diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..a226b7d --- /dev/null +++ b/install.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO="MethodWhite/oura" +VERSION="${1:-latest}" +INSTALL_DIR="${OURA_INSTALL_DIR:-/usr/local/bin}" +CONFIG_DIR="${OURA_CONFIG_DIR:-$HOME/.config/oura}" + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +CYAN='\033[0;36m' +NC='\033[0m' + +log() { printf "${GREEN}[✓]${NC} %s\n" "$*"; } +warn() { printf "${YELLOW}[!]${NC} %s\n" "$*"; } +err() { printf "${RED}[✗]${NC} %s\n" "$*"; exit 1; } +info() { printf "${CYAN}[i]${NC} %s\n" "$*"; } + +detect_arch() { + local arch + arch=$(uname -m) + case "$arch" in + x86_64|amd64) echo "x86_64" ;; + aarch64|arm64) echo "aarch64" ;; + *) err "Unsupported architecture: $arch" ;; + esac +} + +detect_os() { + local os + os=$(uname -s) + case "$os" in + Linux) echo "unknown-linux-gnu" ;; + Darwin) echo "apple-darwin" ;; + *) err "Unsupported OS: $os" ;; + esac +} + +get_release_url() { + local os_arch="$1" + if [ "$VERSION" = "latest" ]; then + echo "https://github.com/$REPO/releases/latest/download/oura-${os_arch}" + else + echo "https://github.com/$REPO/releases/download/$VERSION/oura-${os_arch}" + fi +} + +main() { + echo "" + printf "${CYAN}╔══════════════════════════════════════╗${NC}\n" + printf "${CYAN}║ Oura Installer — 0XFFRice ║${NC}\n" + printf "${CYAN}╚══════════════════════════════════════╝${NC}\n" + echo "" + + if [ "$(id -u)" -eq 0 ]; then + warn "Running as root — installing system-wide to $INSTALL_DIR" + else + info "Running as user — installing to $INSTALL_DIR" + if ! mkdir -p "$INSTALL_DIR" 2>/dev/null; then + info "Need sudo to write to $INSTALL_DIR" + exec sudo "$0" "$@" + fi + fi + + local arch vendor os_arch + arch=$(detect_arch) + vendor=$(detect_os) + os_arch="${arch}-${vendor}" + local url + url=$(get_release_url "$os_arch") + + info "Detected: $os_arch" + info "Download: $url" + + local tmpdir + tmpdir=$(mktemp -d) + trap 'rm -rf "$tmpdir"' EXIT + + if command -v curl &>/dev/null; then + curl -fsSL "$url" -o "$tmpdir/oura" || err "Download failed" + elif command -v wget &>/dev/null; then + wget -q "$url" -O "$tmpdir/oura" || err "Download failed" + else + err "Need curl or wget" + fi + + chmod +x "$tmpdir/oura" + "$tmpdir/oura" --version &>/dev/null || err "Binary validation failed" + + mkdir -p "$INSTALL_DIR" + cp "$tmpdir/oura" "$INSTALL_DIR/oura" + log "Installed: $INSTALL_DIR/oura" + + mkdir -p "$CONFIG_DIR" + if [ ! -f "$CONFIG_DIR/config.toml" ]; then + cat > "$CONFIG_DIR/config.toml" << 'TOML' +[loop_engine] +max_iterations = 20 +convergence_threshold = 90.0 +feedback_sources = ["test", "lint"] + +[github] +enabled = true +default_owner = "MethodWhite" +default_repo = "my-project" +auto_commit = true +auto_pr = true + +# [synapsis] # Optional: uncomment for Synapsis integration (separate project) +# enabled = true +# endpoint = "http://localhost:7438" +TOML + log "Config created: $CONFIG_DIR/config.toml" + else + warn "Config exists at $CONFIG_DIR/config.toml — skipping" + fi + + if echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then + log "Ready to use. Run: oura --help" + else + warn "$INSTALL_DIR is not in PATH. Add it:" + warn " export PATH=\"\$PATH:$INSTALL_DIR\"" + fi + + echo "" + log "Oura installed successfully!" + "$INSTALL_DIR/oura" version 2>/dev/null || true + echo "" +} + +main "$@" diff --git a/mcp-config/cursor.json b/mcp-config/cursor.json index 0d58f9b..0018fc0 100644 --- a/mcp-config/cursor.json +++ b/mcp-config/cursor.json @@ -1,6 +1,7 @@ { - "mcp": { + "mcpServers": { "oura": { + "type": "stdio", "command": "/home/methodwhite/Proyectos/oura/target/release/oura" } } diff --git a/mcp-config/opencode.json b/mcp-config/opencode.json index 7c10f1d..0018fc0 100644 --- a/mcp-config/opencode.json +++ b/mcp-config/opencode.json @@ -2,10 +2,7 @@ "mcpServers": { "oura": { "type": "stdio", - "command": "/home/methodwhite/Proyectos/oura/target/release/oura", - "env": { - "SYNAPSIS_ENDPOINT": "http://localhost:7438" - } + "command": "/home/methodwhite/Proyectos/oura/target/release/oura" } } } diff --git a/src/agents.rs b/src/agents.rs index 201381f..6b45e70 100644 --- a/src/agents.rs +++ b/src/agents.rs @@ -88,6 +88,45 @@ const DANGEROUS_PATTERNS: &[DangerousPattern] = &[ ), ]; +struct CompiledPattern { + type_: &'static str, + severity: &'static str, + regex: regex::Regex, + description: &'static str, + recommendation: &'static str, + langs: &'static [&'static str], +} + +fn compiled_patterns() -> &'static [CompiledPattern] { + static PATTERNS: std::sync::OnceLock> = std::sync::OnceLock::new(); + PATTERNS.get_or_init(|| { + DANGEROUS_PATTERNS + .iter() + .filter_map( + |(type_, severity, pattern, description, recommendation, langs)| { + match regex::Regex::new(pattern) { + Ok(regex) => Some(CompiledPattern { + type_, + severity, + regex, + description, + recommendation, + langs, + }), + Err(e) => { + eprintln!( + "[Oura] Warning: failed to compile security pattern '{}': {}", + type_, e + ); + None + } + } + }, + ) + .collect() + }) +} + impl SecurityAuditor { pub fn new() -> Self { Self @@ -95,9 +134,17 @@ impl SecurityAuditor { pub fn audit(&self, files: &[String]) -> Vec { let mut entries = vec![]; + let compiled = compiled_patterns(); for file in files { - let ext = std::path::Path::new(file) + let path = std::path::Path::new(file); + if let Ok(meta) = path.metadata() { + if meta.len() > 10_000_000 { + continue; + } + } + + let ext = path .extension() .and_then(|e| e.to_str()) .unwrap_or("") @@ -105,12 +152,25 @@ impl SecurityAuditor { let content = match fs::read_to_string(file) { Ok(c) => c, - Err(_) => continue, + Err(e) => { + eprintln!( + "[Oura] SecurityAuditor: skipping unreadable file {}: {}", + file, e + ); + continue; + } }; let lines: Vec<&str> = content.lines().collect(); - for (type_, severity, pattern, description, recommendation, langs) in DANGEROUS_PATTERNS + for CompiledPattern { + type_, + severity, + regex, + description, + recommendation, + langs, + } in compiled { // Skip pattern if its language list doesn't match the file extension let ext_match = |ext: &str, langs: &[&str]| -> bool { @@ -124,22 +184,17 @@ impl SecurityAuditor { "php" => "php", "sql" => "sql", "html" | "htm" | "xhtml" => "html", - _ => "", + _ => return false, }; - mapped.is_empty() || langs.contains(&mapped) + langs.contains(&mapped) }; if !ext_match(&ext, langs) { continue; } - let re = match Regex::new(pattern) { - Ok(r) => r, - Err(_) => continue, - }; - for (i, line) in lines.iter().enumerate() { - if re.is_match(line) { + if regex.is_match(line) { entries.push(SecurityAuditEntry { type_: type_.to_string(), severity: severity.to_string(), @@ -161,7 +216,7 @@ pub struct RefactorEngine; const CLEAN_CODE_PATTERNS: &[(&str, &str, &str)] = &[ ( - "catch\\s*\\([^)]*\\)\\s*\\{[^}]*\\}", + "catch\\s*\\([^)]*\\)\\s*\\{", "Generic catch clause", "Type the error or add specific error handling", ), diff --git a/src/config.rs b/src/config.rs index bb43681..b45c9f8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,17 +10,24 @@ pub struct Config { #[serde(default)] pub github: GitHubConfig, #[serde(default)] - pub synapsis: SynapsisConfig, - #[serde(default)] pub logging: LoggingConfig, + #[serde(default)] + pub connector: ConnectorConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct GeneralConfig { + #[serde(default)] pub data_dir: Option, + #[serde(default = "default_profile")] pub profile: String, } +fn default_profile() -> String { + "default".into() +} + impl Default for GeneralConfig { fn default() -> Self { Self { @@ -30,12 +37,44 @@ impl Default for GeneralConfig { } } +fn default_threshold() -> f64 { + 90.0 +} +fn default_feedback_sources() -> Vec { + vec!["test".into(), "lint".into()] +} +fn default_max_runtime() -> u64 { + 3600 +} +fn default_max_iterations() -> u32 { + 20 +} +fn default_pr_prefix() -> String { + "[Oura] ".into() +} +fn default_logging_level() -> String { + "info".into() +} +fn default_logging_format() -> String { + "text".into() +} +fn default_logging_output() -> String { + "stderr".into() +} + #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct LoopEngineConfig { + #[serde(default = "default_max_iterations")] pub max_iterations: u32, + #[serde(default = "default_threshold")] pub convergence_threshold: f64, + #[serde(default = "default_feedback_sources")] pub feedback_sources: Vec, + #[serde(default)] pub working_directory: Option, + #[serde(default = "default_max_runtime")] + pub max_runtime_secs: u64, } impl Default for LoopEngineConfig { @@ -45,21 +84,43 @@ impl Default for LoopEngineConfig { convergence_threshold: 90.0, feedback_sources: vec!["test".into(), "lint".into()], working_directory: None, + max_runtime_secs: 3600, } } } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct GitHubConfig { + #[serde(default)] pub enabled: bool, + #[serde(default)] pub default_owner: String, + #[serde(default)] pub default_repo: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub token: Option, + #[serde(default)] pub auto_commit: bool, + #[serde(default)] pub auto_pr: bool, + #[serde(default = "default_pr_prefix")] pub pr_title_prefix: String, + #[serde(default)] pub workflows_enabled: bool, - pub repos: Vec, +} + +#[allow(dead_code)] +impl GitHubConfig { + pub fn masked_token(&self) -> Option { + self.token.as_ref().map(|t| { + if t.len() > 8 { + format!("{}…{}", &t[..4], &t[t.len() - 4..]) + } else { + "********".into() + } + }) + } } impl Default for GitHubConfig { @@ -73,55 +134,73 @@ impl Default for GitHubConfig { auto_pr: false, pr_title_prefix: "[Oura] ".into(), workflows_enabled: true, - repos: vec![], } } } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RepoConfig { - pub owner: String, - pub repo: String, - pub branch: String, - pub base_branch: String, - pub auto_sync: bool, - pub workflows: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SynapsisConfig { - pub enabled: bool, - pub endpoint: String, - pub mcp_command: String, +#[serde(deny_unknown_fields)] +pub struct LoggingConfig { + #[serde(default = "default_logging_level")] + pub level: String, + #[serde(default = "default_logging_format")] + pub format: String, + #[serde(default = "default_logging_output")] + pub output: String, } -impl Default for SynapsisConfig { +impl Default for LoggingConfig { fn default() -> Self { Self { - enabled: true, - endpoint: "http://localhost:7438".into(), - mcp_command: "synapsis-mcp".into(), + level: "info".into(), + format: "text".into(), + output: "stderr".into(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LoggingConfig { - pub level: String, - pub format: String, - pub output: String, +#[serde(deny_unknown_fields)] +pub struct ConnectorConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub transport: String, + #[serde(default)] + pub server_url: String, + #[serde(default)] + pub host: String, + #[serde(default)] + pub port: u16, + #[serde(default)] + pub endpoint: String, + #[serde(default)] + pub tools: Vec, + #[serde(default)] + pub auto_call: bool, } -impl Default for LoggingConfig { +impl Default for ConnectorConfig { fn default() -> Self { Self { - level: "info".into(), - format: "text".into(), - output: "stderr".into(), + enabled: false, + transport: "http".into(), + server_url: "http://localhost:7438".into(), + host: "127.0.0.1".into(), + port: 7439, + endpoint: "/message".into(), + tools: vec![], + auto_call: false, } } } +fn quiet_eprint(msg: &str) { + if std::env::var("OURA_QUIET").is_err() { + eprintln!("{}", msg); + } +} + impl Config { pub fn load() -> Self { let config_paths = vec![ @@ -132,26 +211,35 @@ impl Config { for path in config_paths.into_iter().flatten() { if path.exists() { - let content = std::fs::read_to_string(&path).unwrap_or_default(); + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + quiet_eprint(&format!( + "[Oura] Warning: couldn't read config at {}: {}", + path.display(), + e + )); + quiet_eprint("[Oura] Using default configuration"); + return Self::apply_env_overrides(Config::default()); + } + }; match toml::from_str(&content) { Ok(config) => { - if std::env::var("OURA_QUIET").is_err() && std::env::var("QUIET").is_err() { - eprintln!("[Oura] Loaded config from: {}", path.display()); - } + quiet_eprint(&format!("[Oura] Loaded config from: {}", path.display())); return Self::apply_env_overrides(config); } Err(e) => { - eprintln!( + quiet_eprint(&format!( "[Oura] Warning: failed to parse config at {}: {}", path.display(), e - ); + )); } } } } - eprintln!("[Oura] Using default configuration"); + quiet_eprint("[Oura] Using default configuration"); Self::apply_env_overrides(Config::default()) } @@ -162,8 +250,10 @@ impl Config { } } if let Ok(val) = std::env::var("OURA_CONVERGENCE_THRESHOLD") { - if let Ok(n) = val.parse() { - config.loop_engine.convergence_threshold = n; + if let Ok(n) = val.parse::() { + if n.is_finite() && (0.0..=100.0).contains(&n) { + config.loop_engine.convergence_threshold = n; + } } } if let Ok(val) = std::env::var("OURA_GITHUB_TOKEN") { @@ -178,9 +268,6 @@ impl Config { if let Ok(val) = std::env::var("OURA_GITHUB_ENABLED") { config.github.enabled = val == "true" || val == "1"; } - if let Ok(val) = std::env::var("OURA_SYNAPSIS_ENDPOINT") { - config.synapsis.endpoint = val; - } if let Ok(val) = std::env::var("OURA_WORKING_DIR") { config.loop_engine.working_directory = Some(val); } @@ -194,7 +281,10 @@ impl Config { let config = Config::default(); let toml_str = toml::to_string_pretty(&config)?; std::fs::write(path, toml_str)?; - eprintln!("[Oura] Default config written to: {}", path.display()); + quiet_eprint(&format!( + "[Oura] Default config written to: {}", + path.display() + )); Ok(()) } @@ -222,7 +312,6 @@ mod tests { assert_eq!(config.loop_engine.max_iterations, 20); assert_eq!(config.loop_engine.convergence_threshold, 90.0); assert!(!config.github.enabled); - assert!(config.synapsis.enabled); } #[test] @@ -238,6 +327,7 @@ mod tests { assert_eq!(config.max_iterations, 20); assert_eq!(config.convergence_threshold, 90.0); assert_eq!(config.feedback_sources, vec!["test", "lint"]); + assert_eq!(config.max_runtime_secs, 3600); } #[test] @@ -249,13 +339,6 @@ mod tests { assert_eq!(config.pr_title_prefix, "[Oura] "); } - #[test] - fn test_synapsis_config_default() { - let config = SynapsisConfig::default(); - assert!(config.enabled); - assert_eq!(config.endpoint, "http://localhost:7438"); - } - #[test] fn test_logging_config_default() { let config = LoggingConfig::default(); @@ -277,6 +360,47 @@ mod tests { ); } + #[test] + fn test_config_partial_section() { + let toml_str = "[loop_engine]\nmax_iterations = 5\n".to_string(); + let config: Config = toml::from_str(&toml_str).unwrap(); + assert_eq!(config.loop_engine.max_iterations, 5); + assert_eq!(config.loop_engine.convergence_threshold, 90.0); + assert_eq!(config.loop_engine.max_runtime_secs, 3600); + } + + #[test] + fn test_config_unknown_key_rejected() { + let toml_str = "[loop_engine]\nunknown_key = true\nmax_iterations = 5\n".to_string(); + let result: Result = toml::from_str(&toml_str); + assert!(result.is_err()); + } + + #[test] + fn test_convergence_threshold_env_nan_rejected() { + std::env::set_var("OURA_CONVERGENCE_THRESHOLD", "nan"); + let config = Config::apply_env_overrides(Config::default()); + // nan should be rejected, default remains + assert_eq!(config.loop_engine.convergence_threshold, 90.0); + std::env::remove_var("OURA_CONVERGENCE_THRESHOLD"); + } + + #[test] + fn test_convergence_threshold_env_out_of_range_rejected() { + std::env::set_var("OURA_CONVERGENCE_THRESHOLD", "200"); + let config = Config::apply_env_overrides(Config::default()); + assert_eq!(config.loop_engine.convergence_threshold, 90.0); + std::env::remove_var("OURA_CONVERGENCE_THRESHOLD"); + } + + #[test] + fn test_convergence_threshold_env_valid() { + std::env::set_var("OURA_CONVERGENCE_THRESHOLD", "85.5"); + let config = Config::apply_env_overrides(Config::default()); + assert_eq!(config.loop_engine.convergence_threshold, 85.5); + std::env::remove_var("OURA_CONVERGENCE_THRESHOLD"); + } + #[test] fn test_apply_env_overrides() { std::env::set_var("OURA_MAX_ITERATIONS", "50"); diff --git a/src/connector.rs b/src/connector.rs new file mode 100644 index 0000000..9910ac2 --- /dev/null +++ b/src/connector.rs @@ -0,0 +1,126 @@ +use anyhow::{Context, Result}; +use quinn::{ClientConfig, Endpoint, EndpointConfig, TransportConfig}; +use std::sync::Arc; +use std::time::Duration; + +pub struct QuicConnector { + endpoint: Endpoint, +} + +impl QuicConnector { + pub fn new() -> Result { + let mut transport = TransportConfig::default(); + transport.max_idle_timeout(Some(Duration::from_secs(30).try_into().unwrap())); + transport.keep_alive_interval(Some(Duration::from_secs(5))); + + let crypto = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(SkipVerification)) + .with_no_client_auth(); + + let quic_config = quinn::crypto::rustls::QuicClientConfig::try_from(crypto)?; + let mut client_config = ClientConfig::new(Arc::new(quic_config)); + client_config.transport_config(Arc::new(transport)); + + let socket = std::net::UdpSocket::bind("0.0.0.0:0")?; + let mut endpoint = Endpoint::new( + EndpointConfig::default(), + None::, + socket, + Arc::new(quinn::TokioRuntime), + )?; + endpoint.set_default_client_config(client_config); + + Ok(Self { endpoint }) + } + + pub async fn connect(&self, host: &str, port: u16) -> Result { + let addr: std::net::SocketAddr = format!("{}:{}", host, port) + .parse() + .context("Invalid host:port")?; + Ok(self.endpoint.connect(addr, host)?.await?) + } + + pub async fn call_tool( + &self, + host: &str, + port: u16, + tool_name: &str, + arguments: &serde_json::Value, + ) -> Result { + let conn = self + .connect(host, port) + .await + .context("QUIC connection failed")?; + let (mut send, mut recv) = conn.open_bi().await.context("Failed to open QUIC stream")?; + + let request = serde_json::json!({ + "jsonrpc": "2.0", "id": "1", "method": "tools/call", + "params": { "name": tool_name, "arguments": arguments } + }); + let body = serde_json::to_vec(&request)?; + + let len = body.len() as u32; + send.write_all(&len.to_be_bytes()).await?; + send.write_all(&body).await?; + let _ = send.finish(); + + let mut len_buf = [0u8; 4]; + recv.read_exact(&mut len_buf).await?; + let resp_len = u32::from_be_bytes(len_buf) as usize; + + let mut resp_buf = vec![0u8; resp_len]; + recv.read_exact(&mut resp_buf).await?; + + Ok(String::from_utf8(resp_buf)?) + } +} + +#[derive(Debug)] +struct SkipVerification; + +impl rustls::client::danger::ServerCertVerifier for SkipVerification { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + rustls::SignatureScheme::RSA_PKCS1_SHA256, + rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + rustls::SignatureScheme::RSA_PSS_SHA256, + rustls::SignatureScheme::RSA_PKCS1_SHA384, + rustls::SignatureScheme::ECDSA_NISTP384_SHA384, + rustls::SignatureScheme::RSA_PSS_SHA384, + rustls::SignatureScheme::RSA_PKCS1_SHA512, + rustls::SignatureScheme::ECDSA_NISTP521_SHA512, + rustls::SignatureScheme::RSA_PSS_SHA512, + rustls::SignatureScheme::ED25519, + ] + } +} diff --git a/src/engine.rs b/src/engine.rs index 174f5d4..90613dc 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,7 +1,13 @@ +use crate::config::{ConnectorConfig, LoopEngineConfig}; use crate::error::{OuraError, Result}; use crate::events::{EventBus, OuraEvent}; -use crate::feedback::{ClippyFeedbackCollector, ProfileFeedbackCollector, TestFeedbackCollector}; -use crate::traits::{CompositeFeedbackCollector, DefaultCommandRunner, FeedbackCollector}; +use crate::feedback::{ + ClippyFeedbackCollector, ConnectorFeedbackCollector, ProfileFeedbackCollector, + TestFeedbackCollector, +}; +use crate::traits::{ + CommandRunner, CompositeFeedbackCollector, DefaultCommandRunner, FeedbackCollector, +}; use crate::types::*; use chrono::Utc; use std::sync::{ @@ -11,6 +17,20 @@ use std::sync::{ use tokio::sync::Notify; use uuid::Uuid; +fn lock_state( + state: &Arc>>, +) -> Result>> { + state + .lock() + .map_err(|e| OuraError::Internal(format!("Lock poisoned: {}", e))) +} + +fn lock_value(mutex: &Arc>) -> Result> { + mutex + .lock() + .map_err(|e| OuraError::Internal(format!("Lock poisoned: {}", e))) +} + pub struct LoopEngine { state: Arc>>, max_iterations: Arc>, @@ -27,31 +47,63 @@ impl Drop for LoopEngine { fn drop(&mut self) { self.stop_flag.store(true, Ordering::SeqCst); self.stop_notify.notify_waiters(); + } +} + +impl LoopEngine { + #[allow(dead_code)] + pub async fn shutdown(&mut self) { + self.stop_flag.store(true, Ordering::SeqCst); + self.stop_notify.notify_waiters(); if let Some(handle) = self.loop_handle.take() { - handle.abort(); + tokio::select! { + _ = handle => {}, + _ = tokio::time::sleep(std::time::Duration::from_secs(3)) => {}, + } } } } impl LoopEngine { - pub fn new(max_iterations: u32, convergence_threshold: f64) -> Self { - let command_runner = Box::new(DefaultCommandRunner); + pub fn new(config: &LoopEngineConfig, connector_config: &ConnectorConfig) -> Self { let mut composite = CompositeFeedbackCollector::new(); - composite.add(Box::new(TestFeedbackCollector::new( - Box::new(DefaultCommandRunner), - "cargo test 2>&1".to_string(), - ))); - composite.add(Box::new(ClippyFeedbackCollector::new( - Box::new(DefaultCommandRunner), - "cargo clippy 2>&1".to_string(), - ))); - composite.add(Box::new(ProfileFeedbackCollector)); + let make_runner = |dir: &Option| -> Box { + match dir { + Some(d) => Box::new(DefaultCommandRunner::new_with_dir( + std::path::PathBuf::from(d), + )), + None => Box::new(DefaultCommandRunner::new()), + } + }; + let runner = make_runner(&config.working_directory); + if config.feedback_sources.contains(&"test".to_string()) { + composite.add(Box::new(TestFeedbackCollector::new(runner))); + } + let runner = make_runner(&config.working_directory); + if config.feedback_sources.contains(&"lint".to_string()) { + composite.add(Box::new(ClippyFeedbackCollector::new(runner))); + } + if config.feedback_sources.contains(&"profile".to_string()) { + let collector = match &config.working_directory { + Some(d) => ProfileFeedbackCollector::new_with_dir(std::path::PathBuf::from(d)), + None => ProfileFeedbackCollector::new(), + }; + composite.add(Box::new(collector)); + } + if connector_config.enabled + && !connector_config.server_url.is_empty() + && !connector_config.tools.is_empty() + { + composite.add(Box::new(ConnectorFeedbackCollector::new( + connector_config.clone(), + ))); + } Self { state: Arc::new(Mutex::new(None)), - max_iterations: Arc::new(Mutex::new(max_iterations)), - convergence_threshold: Arc::new(Mutex::new(convergence_threshold)), - max_runtime_secs: Arc::new(Mutex::new(3600)), + max_iterations: Arc::new(Mutex::new(config.max_iterations)), + convergence_threshold: Arc::new(Mutex::new(config.convergence_threshold)), + max_runtime_secs: Arc::new(Mutex::new(config.max_runtime_secs)), stop_flag: Arc::new(AtomicBool::new(false)), stop_notify: Arc::new(Notify::new()), feedback_collector: Arc::new(composite), @@ -60,6 +112,7 @@ impl LoopEngine { } } + #[allow(dead_code)] pub fn with_feedback_collector( max_iterations: u32, convergence_threshold: f64, @@ -82,7 +135,12 @@ impl LoopEngine { &self.event_bus } - pub async fn start(&mut self, goal: &str) -> Result { + pub async fn start( + &mut self, + goal: &str, + manual: bool, + max_iterations: Option, + ) -> Result { if let Some(ref handle) = self.loop_handle { if !handle.is_finished() { return Err(OuraError::LoopAlreadyRunning); @@ -92,7 +150,7 @@ impl LoopEngine { self.stop_flag.store(false, Ordering::SeqCst); { - let state_guard = self.state.lock().unwrap(); + let state_guard = lock_state(&self.state)?; if let Some(ref state) = *state_guard { if state.status == "running" { return Err(OuraError::LoopAlreadyRunning); @@ -100,10 +158,18 @@ impl LoopEngine { } } + let max_iter = max_iterations.unwrap_or(*lock_value(&self.max_iterations)?); + let threshold = *lock_value(&self.convergence_threshold)?; + let runtime = *lock_value(&self.max_runtime_secs)?; let initial_state = LoopState { id: Uuid::new_v4().to_string(), goal: goal.into(), - config: OuraConfig::default(), + config: OuraConfig { + max_iterations: max_iter, + convergence_threshold: threshold, + max_runtime_secs: runtime, + ..OuraConfig::default() + }, current_iteration: 0, history: vec![], status: "running".into(), @@ -111,7 +177,7 @@ impl LoopEngine { }; { - let mut state_guard = self.state.lock().unwrap(); + let mut state_guard = lock_state(&self.state)?; *state_guard = Some(initial_state.clone()); } @@ -121,8 +187,12 @@ impl LoopEngine { timestamp: Utc::now().to_rfc3339(), }); + if manual { + return Ok(initial_state); + } + let state_clone = self.state.clone(); - let max_iter_clone = self.max_iterations.clone(); + let max_iter_limit = max_iter; let threshold_clone = self.convergence_threshold.clone(); let runtime_clone = self.max_runtime_secs.clone(); let stop_flag_clone = self.stop_flag.clone(); @@ -133,11 +203,23 @@ impl LoopEngine { let handle = tokio::spawn(async move { let loop_start = std::time::Instant::now(); - let max_runtime = *runtime_clone.lock().unwrap(); + let max_runtime = match runtime_clone.lock() { + Ok(guard) => *guard, + Err(_) => { + tracing::error!("Lock poisoned in runtime"); + return; + } + }; loop { if stop_flag_clone.load(Ordering::SeqCst) { - let mut state_guard = state_clone.lock().unwrap(); + let mut state_guard = match state_clone.lock() { + Ok(g) => g, + Err(_) => { + tracing::error!("Lock poisoned"); + return; + } + }; if let Some(ref mut st) = *state_guard { st.status = "stopped".into(); } @@ -153,7 +235,13 @@ impl LoopEngine { } if max_runtime > 0 && loop_start.elapsed().as_secs() > max_runtime { - let mut state_guard = state_clone.lock().unwrap(); + let mut state_guard = match state_clone.lock() { + Ok(g) => g, + Err(_) => { + tracing::error!("Lock poisoned"); + return; + } + }; if let Some(ref mut st) = *state_guard { st.status = "failed".into(); } @@ -166,7 +254,13 @@ impl LoopEngine { } let iteration_num = { - let mut state_guard = state_clone.lock().unwrap(); + let mut state_guard = match state_clone.lock() { + Ok(g) => g, + Err(_) => { + tracing::error!("Lock poisoned"); + return; + } + }; let st = match state_guard.as_mut() { Some(s) => s, None => break, @@ -174,7 +268,7 @@ impl LoopEngine { if st.status != "running" { break; } - st.current_iteration += 1; + st.current_iteration = st.current_iteration.saturating_add(1); st.current_iteration }; @@ -184,10 +278,45 @@ impl LoopEngine { timestamp: Utc::now().to_rfc3339(), }); - let max_iter = *max_iter_clone.lock().unwrap(); - let threshold = *threshold_clone.lock().unwrap(); + let threshold = match threshold_clone.lock() { + Ok(g) => *g, + Err(_) => { + tracing::error!("Lock poisoned in threshold"); + return; + } + }; - let feedback_entries = feedback_collector.collect().await; + let feedback_entries = { + let collect = feedback_collector.collect(); + tokio::select! { + biased; + _ = stop_notify_clone.notified() => { + let mut state_guard = match state_clone.lock() { + Ok(g) => g, + Err(_) => { tracing::error!("Lock poisoned"); return; } + }; + if let Some(ref mut st) = *state_guard { + st.status = "stopped".into(); + } + event_bus.publish(OuraEvent::LoopStopped { + loop_id: loop_id.clone(), + iterations: iteration_num, + timestamp: Utc::now().to_rfc3339(), + }); + break; + } + _ = tokio::time::sleep(std::time::Duration::from_secs(600)) => { + tracing::error!("Feedback collection timed out after 600s"); + event_bus.publish(OuraEvent::Error { + loop_id: Some(loop_id.clone()), + message: "Feedback collection timed out".to_string(), + timestamp: Utc::now().to_rfc3339(), + }); + Vec::new() + } + result = collect => result, + } + }; event_bus.publish(OuraEvent::FeedbackCollected { loop_id: loop_id.clone(), @@ -208,13 +337,11 @@ impl LoopEngine { let mut score = 100.0 - (error_count * 15.0) - (warning_count * 5.0); score = score.clamp(0.0, 100.0); - let has_feedback = !feedback_entries.is_empty(); - let converged = has_feedback - && (score >= threshold || (error_count == 0.0 && warning_count == 0.0)); + let converged = score >= threshold; let status = if converged { - "converged" - } else if iteration_num >= max_iter { + "completed" + } else if iteration_num >= max_iter_limit { "failed" } else { "running" @@ -239,7 +366,13 @@ impl LoopEngine { }); { - let mut state_guard = state_clone.lock().unwrap(); + let mut state_guard = match state_clone.lock() { + Ok(g) => g, + Err(_) => { + tracing::error!("Lock poisoned in state update"); + return; + } + }; let st = match state_guard.as_mut() { Some(s) => s, None => break, @@ -252,7 +385,7 @@ impl LoopEngine { final_score: score, timestamp: Utc::now().to_rfc3339(), }); - } else if iteration_num >= max_iter { + } else if iteration_num >= max_iter_limit { st.status = "failed".into(); } st.history.push(result); @@ -265,7 +398,10 @@ impl LoopEngine { tokio::select! { _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {} _ = stop_notify_clone.notified() => { - let mut state_guard = state_clone.lock().unwrap(); + let mut state_guard = match state_clone.lock() { + Ok(g) => g, + Err(_) => { tracing::error!("Lock poisoned in stop notify"); return; } + }; if let Some(ref mut st) = *state_guard { st.status = "stopped".into(); } @@ -291,20 +427,30 @@ impl LoopEngine { } } - let mut state_guard = self - .state - .lock() - .map_err(|_| OuraError::Internal("Lock poisoned".to_string()))?; - let state = state_guard.as_mut().ok_or(OuraError::NoActiveLoop)?; + let iteration_num = { + let mut state_guard = self + .state + .lock() + .map_err(|_| OuraError::Internal("Lock poisoned".to_string()))?; + let state = state_guard.as_mut().ok_or(OuraError::NoActiveLoop)?; - if state.status != "running" { - return Err(OuraError::LoopNotRunning(state.status.clone())); - } + if state.status != "running" { + return Err(OuraError::LoopNotRunning(state.status.clone())); + } - state.current_iteration += 1; - let iteration_num = state.current_iteration; + state.current_iteration = state.current_iteration.saturating_add(1); + state.current_iteration + }; - let feedback_entries = self.feedback_collector.collect().await; + let feedback_entries = tokio::time::timeout( + std::time::Duration::from_secs(600), + self.feedback_collector.collect(), + ) + .await + .unwrap_or_else(|_| { + tracing::error!("Feedback collection timed out during manual iteration"); + vec![] + }); let error_count = feedback_entries .iter() @@ -318,15 +464,13 @@ impl LoopEngine { let mut score = 100.0 - (error_count * 15.0) - (warning_count * 5.0); score = score.clamp(0.0, 100.0); - let has_feedback = !feedback_entries.is_empty(); - let converged = has_feedback - && (score >= *self.convergence_threshold.lock().unwrap() - || (error_count == 0.0 && warning_count == 0.0)); + let threshold = *lock_value(&self.convergence_threshold)?; + let converged = score >= threshold; - let max_iter = *self.max_iterations.lock().unwrap(); + let max_iter = *lock_value(&self.max_iterations)?; let status = if converged { - "converged" + "completed" } else if iteration_num >= max_iter { "failed" } else { @@ -343,6 +487,12 @@ impl LoopEngine { score, }; + let mut state_guard = self + .state + .lock() + .map_err(|_| OuraError::Internal("Lock poisoned".to_string()))?; + let state = state_guard.as_mut().ok_or(OuraError::NoActiveLoop)?; + if converged { state.status = "completed".into(); } else if iteration_num >= max_iter { @@ -353,85 +503,102 @@ impl LoopEngine { Ok(result) } - pub fn stop(&mut self) -> u32 { + pub fn stop(&mut self) -> Result { self.stop_flag.store(true, Ordering::SeqCst); self.stop_notify.notify_waiters(); - let state_guard = self.state.lock().unwrap(); - state_guard - .as_ref() - .map(|s| s.current_iteration) - .unwrap_or(0) + let mut guard = self + .state + .lock() + .map_err(|e| OuraError::Internal(format!("Lock poisoned: {}", e)))?; + if let Some(ref mut st) = *guard { + st.status = "stopped".into(); + Ok(st.current_iteration) + } else { + Ok(0) + } } - pub fn get_state(&self) -> Option { - self.state.lock().unwrap().clone() + pub fn get_state(&self) -> Result> { + lock_state(&self.state).map(|g| g.clone()) } - pub fn get_results(&self) -> Vec { - self.state - .lock() - .unwrap() + pub fn get_results(&self) -> Result> { + let guard = lock_state(&self.state)?; + Ok(guard .as_ref() .map(|s| s.history.clone()) - .unwrap_or_default() + .unwrap_or_default()) } - pub fn update_max_iterations(&mut self, max: u32) { - *self.max_iterations.lock().unwrap() = max; + pub fn update_max_iterations(&mut self, max: u32) -> Result<()> { + *lock_value(&self.max_iterations)? = max; + Ok(()) } - pub fn update_convergence_threshold(&mut self, threshold: f64) { - *self.convergence_threshold.lock().unwrap() = threshold; + pub fn update_convergence_threshold(&mut self, threshold: f64) -> Result<()> { + *lock_value(&self.convergence_threshold)? = threshold; + Ok(()) } - pub fn max_iterations(&self) -> u32 { - *self.max_iterations.lock().unwrap() + pub fn max_iterations(&self) -> Result { + lock_value(&self.max_iterations).map(|g| *g) } - pub fn convergence_threshold(&self) -> f64 { - *self.convergence_threshold.lock().unwrap() + pub fn convergence_threshold(&self) -> Result { + lock_value(&self.convergence_threshold).map(|g| *g) } } #[cfg(test)] mod tests { use super::*; + use crate::config::{ConnectorConfig, LoopEngineConfig}; + + fn test_config(max_iter: u32, threshold: f64) -> LoopEngineConfig { + LoopEngineConfig { + max_iterations: max_iter, + convergence_threshold: threshold, + feedback_sources: vec!["test".into(), "lint".into(), "profile".into()], + working_directory: None, + max_runtime_secs: 3600, + } + } #[tokio::test] async fn test_loop_engine_creation() { - let engine = LoopEngine::new(10, 95.0); - assert!(engine.get_state().is_none()); + let engine = LoopEngine::new(&test_config(10, 95.0), &ConnectorConfig::default()); + assert!(engine.get_state().unwrap().is_none()); } #[tokio::test] async fn test_loop_engine_start_stop() { - let mut engine = LoopEngine::new(5, 90.0); - let result = engine.start("test goal").await; + let mut engine = LoopEngine::new(&test_config(5, 90.0), &ConnectorConfig::default()); + let result = engine.start("test goal", false, None).await; assert!(result.is_ok()); - let state = engine.get_state(); + let state = engine.get_state().unwrap(); assert!(state.is_some()); assert_eq!(state.unwrap().status, "running"); - let iters = engine.stop(); + let iters = engine.stop().unwrap(); assert_eq!(iters, 0); } #[tokio::test] async fn test_loop_engine_update_config() { - let mut engine = LoopEngine::new(10, 90.0); - engine.update_max_iterations(20); - engine.update_convergence_threshold(95.0); + let mut engine = LoopEngine::new(&test_config(10, 90.0), &ConnectorConfig::default()); + engine.update_max_iterations(20).unwrap(); + engine.update_convergence_threshold(95.0).unwrap(); - assert_eq!(engine.max_iterations(), 20); - assert_eq!(engine.convergence_threshold(), 95.0); + assert_eq!(engine.max_iterations().unwrap(), 20); + assert_eq!(engine.convergence_threshold().unwrap(), 95.0); } #[tokio::test] async fn test_loop_engine_already_running() { - let mut engine = LoopEngine::new(5, 90.0); - let _ = engine.start("test goal").await; - let result = engine.start("another goal").await; + let mut engine = LoopEngine::new(&test_config(5, 90.0), &ConnectorConfig::default()); + let _ = engine.start("test goal", false, None).await; + let result = engine.start("another goal", false, None).await; assert!(matches!(result, Err(OuraError::LoopAlreadyRunning))); } } diff --git a/src/error.rs b/src/error.rs index e7320fa..61699e6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -14,27 +14,12 @@ pub enum OuraError { #[error("Background loop running, stop it first")] BackgroundLoopRunning, - #[error("Command execution failed: {0}")] - CommandFailed(String), - - #[error("Command timed out after {0} seconds")] - CommandTimeout(u64), - - #[error("Parse error: {0}")] - ParseError(String), - - #[error("Configuration error: {0}")] - ConfigError(String), - #[error("IO error: {0}")] IoError(#[from] std::io::Error), #[error("JSON error: {0}")] JsonError(#[from] serde_json::Error), - #[error("HTTP error: {0}")] - HttpError(String), - #[error("Internal error: {0}")] Internal(String), } @@ -46,16 +31,54 @@ impl OuraError { OuraError::NoActiveLoop => -32002, OuraError::LoopNotRunning(_) => -32003, OuraError::BackgroundLoopRunning => -32004, - OuraError::CommandFailed(_) => -32010, - OuraError::CommandTimeout(_) => -32011, - OuraError::ParseError(_) => -32700, - OuraError::ConfigError(_) => -32602, OuraError::IoError(_) => -32603, OuraError::JsonError(_) => -32603, - OuraError::HttpError(_) => -32603, OuraError::Internal(_) => -32603, } } } pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_codes() { + assert_eq!(OuraError::LoopAlreadyRunning.code(), -32001); + assert_eq!(OuraError::NoActiveLoop.code(), -32002); + assert_eq!(OuraError::LoopNotRunning("test".into()).code(), -32003); + assert_eq!(OuraError::BackgroundLoopRunning.code(), -32004); + assert_eq!(OuraError::Internal("i".into()).code(), -32603); + } + + #[test] + fn test_error_messages() { + assert_eq!( + OuraError::LoopAlreadyRunning.to_string(), + "Loop already running" + ); + assert!(OuraError::LoopNotRunning("stopped".into()) + .to_string() + .contains("stopped")); + assert!(OuraError::Internal("critical".into()) + .to_string() + .contains("critical")); + } + + #[test] + fn test_error_from_io() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let oura_err: OuraError = io_err.into(); + assert_eq!(oura_err.code(), -32603); + } + + #[test] + fn test_result_type() { + let ok: Result = Ok(42); + assert!(ok.is_ok()); + let err: Result = Err(OuraError::NoActiveLoop); + assert!(err.is_err()); + } +} diff --git a/src/events.rs b/src/events.rs index 3cf6e7d..34b1f35 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,9 +1,6 @@ -use serde::{Deserialize, Serialize}; -use std::sync::Arc; use tokio::sync::broadcast; -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] +#[derive(Debug, Clone)] pub enum OuraEvent { LoopStarted { loop_id: String, @@ -47,6 +44,19 @@ pub enum OuraEvent { } impl OuraEvent { + pub fn name(&self) -> &'static str { + match self { + OuraEvent::LoopStarted { .. } => "loop_started", + OuraEvent::LoopStopped { .. } => "loop_stopped", + OuraEvent::LoopCompleted { .. } => "loop_completed", + OuraEvent::IterationStarted { .. } => "iteration_started", + OuraEvent::IterationCompleted { .. } => "iteration_completed", + OuraEvent::FeedbackCollected { .. } => "feedback_collected", + OuraEvent::Error { .. } => "error", + } + } + + #[allow(dead_code)] pub fn timestamp(&self) -> &str { match self { OuraEvent::LoopStarted { timestamp, .. } => timestamp, @@ -72,7 +82,10 @@ impl EventBus { } pub fn publish(&self, event: OuraEvent) { - let _ = self.sender.send(event); + if let Err(e) = self.sender.send(event) { + tracing::warn!("Event dropped (no active receivers)"); + let _ = e.0; + } } pub fn subscribe(&self) -> broadcast::Receiver { @@ -101,11 +114,18 @@ impl EventLogger { loop { match self.receiver.recv().await { Ok(event) => { - let event_type = format!("{:?}", std::mem::discriminant(&event)); + let event_type = event.name(); tracing::info!(event_type, "{}", format_event(&event)); } Err(broadcast::error::RecvError::Closed) => break, - Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!( + skipped = n, + "Event receiver lagged, dropping {} messages", + n + ); + continue; + } } } } diff --git a/src/feedback.rs b/src/feedback.rs index fc8003c..c4034a7 100644 --- a/src/feedback.rs +++ b/src/feedback.rs @@ -1,18 +1,15 @@ +use crate::config::ConnectorConfig; use crate::traits::{CommandRunner, FeedbackCollector}; use crate::types::FeedbackEntry; use async_trait::async_trait; pub struct TestFeedbackCollector { command_runner: Box, - test_command: String, } impl TestFeedbackCollector { - pub fn new(command_runner: Box, test_command: String) -> Self { - Self { - command_runner, - test_command, - } + pub fn new(command_runner: Box) -> Self { + Self { command_runner } } } @@ -21,7 +18,11 @@ impl FeedbackCollector for TestFeedbackCollector { async fn collect(&self) -> Vec { let mut entries = vec![]; - match self.command_runner.run(&self.test_command).await { + match self + .command_runner + .run("cargo", &["test", "--workspace"]) + .await + { Ok(output) => { let passed = extract_number(&output, "passed"); let failed = extract_number(&output, "failed"); @@ -65,15 +66,11 @@ impl FeedbackCollector for TestFeedbackCollector { pub struct ClippyFeedbackCollector { command_runner: Box, - clippy_command: String, } impl ClippyFeedbackCollector { - pub fn new(command_runner: Box, clippy_command: String) -> Self { - Self { - command_runner, - clippy_command, - } + pub fn new(command_runner: Box) -> Self { + Self { command_runner } } } @@ -82,10 +79,20 @@ impl FeedbackCollector for ClippyFeedbackCollector { async fn collect(&self) -> Vec { let mut entries = vec![]; - match self.command_runner.run(&self.clippy_command).await { + match self + .command_runner + .run("cargo", &["clippy", "--workspace"]) + .await + { Ok(output) => { - let warnings = output.matches("warning").count(); - let errors = output.matches("error").count(); + let warnings = output + .lines() + .filter(|l| l.starts_with("warning:") || l.contains("warning: ")) + .count(); + let errors = output + .lines() + .filter(|l| l.starts_with("error:") || l.contains("error: ")) + .count(); if warnings > 0 || errors > 0 { entries.push(FeedbackEntry { source: "clippy".into(), @@ -117,14 +124,79 @@ impl FeedbackCollector for ClippyFeedbackCollector { } } -pub struct ProfileFeedbackCollector; +pub struct ProfileFeedbackCollector { + working_dir: Option, +} + +impl ProfileFeedbackCollector { + pub fn new() -> Self { + Self { working_dir: None } + } + pub fn new_with_dir(dir: std::path::PathBuf) -> Self { + Self { + working_dir: Some(dir), + } + } +} + +impl Default for ProfileFeedbackCollector { + fn default() -> Self { + Self::new() + } +} + +static PROFILE_CACHE: std::sync::OnceLock< + std::sync::Mutex<( + String, + crate::profile::ProjectProfile, + crate::profile::VerifyReport, + )>, +> = std::sync::OnceLock::new(); + +fn get_cached_profile( + cwd: &std::path::Path, +) -> (crate::profile::ProjectProfile, crate::profile::VerifyReport) { + let cache = PROFILE_CACHE.get_or_init(|| { + std::sync::Mutex::new(( + String::new(), + crate::profile::ProjectProfile::detect(cwd), + crate::profile::verify_dependencies(cwd), + )) + }); + if let Ok(mut guard) = cache.lock() { + let cwd_str = cwd.to_string_lossy().to_string(); + if guard.0 != cwd_str { + guard.0 = cwd_str; + guard.1 = crate::profile::ProjectProfile::detect(cwd); + guard.2 = crate::profile::verify_dependencies(cwd); + } + return (guard.1.clone(), guard.2.clone()); + } + ( + crate::profile::ProjectProfile::detect(cwd), + crate::profile::verify_dependencies(cwd), + ) +} #[async_trait] impl FeedbackCollector for ProfileFeedbackCollector { async fn collect(&self) -> Vec { let mut entries = vec![]; - let cwd = std::env::current_dir().unwrap_or_default(); - let profile_result = crate::profile::ProjectProfile::detect(&cwd); + let cwd = self + .working_dir + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + let (profile_result, verify_result) = tokio::task::spawn_blocking({ + let cwd = cwd.clone(); + move || get_cached_profile(&cwd) + }) + .await + .unwrap_or_else(|_| { + ( + crate::profile::ProjectProfile::detect(&cwd), + crate::profile::verify_dependencies(&cwd), + ) + }); let dep_type = if profile_result.dependency_count > 50 { "error" @@ -153,7 +225,6 @@ impl FeedbackCollector for ProfileFeedbackCollector { threshold: None, }); - let verify_result = crate::profile::verify_dependencies(&cwd); if !verify_result.license_issues.is_empty() { entries.push(FeedbackEntry { source: "license".into(), @@ -187,8 +258,10 @@ impl FeedbackCollector for ProfileFeedbackCollector { fn make_extract_regex(label: &str) -> (regex::Regex, regex::Regex) { ( - regex::Regex::new(&format!(r"(?m)^test result:.*?(\d+)\s+{}", label)).unwrap(), - regex::Regex::new(&format!(r"(\d+)\s+{}\b", label)).unwrap(), + regex::Regex::new(&format!(r"(?m)^test result:.*?(\d+)\s+{}", label)) + .expect("Static regex pattern is always valid"), + regex::Regex::new(&format!(r"(\d+)\s+{}\b", label)) + .expect("Static regex pattern is always valid"), ) } @@ -223,6 +296,107 @@ fn extract_number(output: &str, label: &str) -> u32 { 0 } +pub async fn call_mcp_tool( + server_url: &str, + endpoint: &str, + tool_name: &str, + arguments: &serde_json::Value, +) -> Result { + let url = format!("{}{}", server_url.trim_end_matches('/'), endpoint); + let request_body = serde_json::json!({ + "jsonrpc": "2.0", "id": "1", "method": "tools/call", + "params": { "name": tool_name, "arguments": arguments } + }); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("HTTP client build error: {}", e))?; + + let resp = client + .post(&url) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(|e| format!("HTTP request failed: {}", e))?; + + resp.text() + .await + .map_err(|e| format!("HTTP read error: {}", e)) +} + +pub struct ConnectorFeedbackCollector { + config: ConnectorConfig, +} + +impl ConnectorFeedbackCollector { + pub fn new(config: ConnectorConfig) -> Self { + Self { config } + } +} + +#[async_trait] +impl FeedbackCollector for ConnectorFeedbackCollector { + async fn collect(&self) -> Vec { + if !self.config.enabled || self.config.tools.is_empty() { + return vec![]; + } + + let mut entries = vec![]; + let empty_args = serde_json::json!({}); + + for tool_name in &self.config.tools { + let result = if self.config.transport == "quic" { + match crate::connector::QuicConnector::new() { + Ok(conn) => conn + .call_tool(&self.config.host, self.config.port, tool_name, &empty_args) + .await + .map_err(|e| e.to_string()), + Err(e) => Err(e.to_string()), + } + } else { + call_mcp_tool( + &self.config.server_url, + &self.config.endpoint, + tool_name, + &empty_args, + ) + .await + }; + + match result { + Ok(response) => { + let preview: String = response.chars().take(300).collect(); + entries.push(FeedbackEntry { + source: format!("connector:{}", tool_name), + type_: "info".into(), + message: format!( + "Connector {} returned ({} chars)", + tool_name, + response.len() + ), + details: Some(preview), + metric: None, + threshold: None, + }); + } + Err(e) => { + entries.push(FeedbackEntry { + source: format!("connector:{}", tool_name), + type_: "error".into(), + message: format!("Connector {} failed: {}", tool_name, e), + details: None, + metric: Some(0.0), + threshold: None, + }); + } + } + } + entries + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/fs_utils.rs b/src/fs_utils.rs new file mode 100644 index 0000000..fb75ab3 --- /dev/null +++ b/src/fs_utils.rs @@ -0,0 +1,431 @@ +use std::path::{Path, PathBuf}; + +pub fn safe_path(input: &str) -> Result { + let path = Path::new(input); + if !path.exists() { + return Err(format!("Path not found: {}", input)); + } + std::fs::canonicalize(path).map_err(|e| format!("Cannot resolve path {}: {}", input, e)) +} + +pub fn dir_size(path: &Path, max_depth: usize, max_files: usize) -> std::io::Result { + let mut total = 0u64; + let mut visited = std::collections::HashSet::new(); + if path.is_file() { + return Ok(path.metadata()?.len()); + } + walk_size( + path, + 0, + max_depth, + &mut total, + &mut visited, + &mut 0usize, + max_files, + )?; + Ok(total) +} + +fn walk_size( + path: &Path, + depth: usize, + max_depth: usize, + total: &mut u64, + visited: &mut std::collections::HashSet, + count: &mut usize, + max_files: usize, +) -> std::io::Result<()> { + if depth > max_depth || *count > max_files { + return Ok(()); + } + if path.is_dir() { + walk_size_inner(path, depth, max_depth, total, visited, count, max_files) + } else { + Ok(()) + } +} + +fn walk_size_inner( + path: &Path, + depth: usize, + max_depth: usize, + total: &mut u64, + visited: &mut std::collections::HashSet, + count: &mut usize, + max_files: usize, +) -> std::io::Result<()> { + if depth > max_depth || *count > max_files { + return Ok(()); + } + let pk = path_key(path); + if pk != 0 && !visited.insert(pk) { + return Ok(()); + } + if path.is_dir() { + for entry in std::fs::read_dir(path)? { + let e = entry?; + let p = e.path(); + if p.is_symlink() { + continue; + } + if p.is_file() { + *total += e.metadata()?.len(); + *count += 1; + if *count > max_files { + return Ok(()); + } + } else if p.is_dir() { + walk_size_inner(&p, depth + 1, max_depth, total, visited, count, max_files)?; + } + } + } + Ok(()) +} + +fn path_key(path: &Path) -> u64 { + #[cfg(unix)] + { + if let Ok(meta) = path.metadata() { + return std::os::unix::fs::MetadataExt::ino(&meta); + } + } + #[cfg(not(unix))] + { + use std::hash::{Hash, Hasher}; + if let Ok(canonical) = std::fs::canonicalize(path) { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + canonical.hash(&mut hasher); + return hasher.finish(); + } + } + 0 +} + +pub fn format_size(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"]; + if bytes == 0 { + return "0B".into(); + } + let mut size = bytes as f64; + let mut unit = 0; + while size >= 1024.0 && unit < UNITS.len() - 1 { + size /= 1024.0; + unit += 1; + } + format!("{:.1}{}", size, UNITS[unit]) +} + +pub fn find_readme(root: &Path) -> Option { + for name in &[ + "README.md", + "Readme.md", + "readme.md", + "README", + "README.txt", + ] { + let p = root.join(name); + if p.exists() { + return Some(p); + } + } + None +} + +pub fn scan_configs(root: &Path) -> Vec { + let mut configs = Vec::new(); + for name in &[ + "Cargo.toml", + "package.json", + "pyproject.toml", + "go.mod", + "build.gradle", + "CMakeLists.txt", + ] { + if root.join(name).exists() { + configs.push(name.to_string()); + } + } + configs +} + +pub fn collect_entries( + path: &Path, + depth: usize, + max_depth: usize, + max_entries: usize, +) -> Vec<(String, usize, bool, u64)> { + let mut entries = Vec::new(); + let mut visited = std::collections::HashSet::new(); + let mut count = 0usize; + collect_entries_inner( + path, + depth, + max_depth, + &mut entries, + &mut visited, + &mut count, + max_entries, + ); + entries +} + +fn collect_entries_inner( + path: &Path, + depth: usize, + max_depth: usize, + entries: &mut Vec<(String, usize, bool, u64)>, + visited: &mut std::collections::HashSet, + count: &mut usize, + max_entries: usize, +) { + if depth > max_depth || !path.is_dir() || *count > max_entries { + return; + } + let pk = path_key(path); + if pk != 0 && !visited.insert(pk) { + return; + } + if let Ok(readdir) = std::fs::read_dir(path) { + for entry in readdir.flatten() { + let p = entry.path(); + if p.is_symlink() { + continue; + } + let name = p + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if name.starts_with('.') { + continue; + } + let is_dir = p.is_dir(); + let size = if is_dir { + 0 + } else { + p.metadata().map(|m| m.len()).unwrap_or(0) + }; + entries.push((name.clone(), depth, is_dir, size)); + *count += 1; + if *count > max_entries { + return; + } + if is_dir { + collect_entries_inner( + &p, + depth + 1, + max_depth, + entries, + visited, + count, + max_entries, + ); + } + } + } +} + +pub struct CleanupContext { + patterns: Vec, + dir_patterns: Vec, + candidates: Vec<(String, String)>, + total_size: u64, + now: u64, + max_age: u64, + max_depth: usize, +} + +impl CleanupContext { + pub fn new( + patterns: Vec, + dir_patterns: Vec, + now: u64, + max_age: u64, + max_depth: usize, + ) -> Self { + Self { + patterns, + dir_patterns, + candidates: Vec::new(), + total_size: 0, + now, + max_age, + max_depth, + } + } + + pub fn walk(&mut self, path: &Path, depth: usize) { + let mut visited = std::collections::HashSet::new(); + self.walk_inner(path, depth, &mut visited); + } + + fn walk_inner( + &mut self, + path: &Path, + depth: usize, + visited: &mut std::collections::HashSet, + ) { + if depth > self.max_depth { + return; + } + let pk = path_key(path); + if pk != 0 && !visited.insert(pk) { + return; + } + if let Ok(readdir) = std::fs::read_dir(path) { + for entry in readdir.flatten() { + let p = entry.path(); + if p.is_symlink() { + continue; + } + let name = p + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if p.is_dir() { + if self.dir_patterns.iter().any(|d| name == d.as_str()) { + let size = dir_size(&p, 5, 1000).unwrap_or(0); + self.candidates + .push((p.to_string_lossy().to_string(), "dir".into())); + self.total_size += size; + } else { + self.walk_inner(&p, depth + 1, visited); + } + } else if self.patterns.iter().any(|pat| { + if let Some(ext) = pat.strip_prefix('*') { + name.ends_with(ext) + } else { + name == *pat + } + }) { + let aged = self.now.saturating_sub( + entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0), + ); + if aged >= self.max_age { + let size = entry.metadata().map(|m| m.len()).unwrap_or(0); + self.candidates + .push((p.to_string_lossy().to_string(), "file".into())); + self.total_size += size; + } + } + } + } + } + + pub fn candidates(&self) -> &[(String, String)] { + &self.candidates + } + pub fn total_size(&self) -> u64 { + self.total_size + } +} + +pub async fn run_command_timeout( + args: &[&str], + dir: &Path, + secs: u64, +) -> Result { + let program = args[0].to_string(); + let cmd_args: Vec = args[1..].iter().map(|s| s.to_string()).collect(); + let dir = dir.to_path_buf(); + + let result = tokio::time::timeout( + std::time::Duration::from_secs(secs), + tokio::process::Command::new(&program) + .args(&cmd_args) + .current_dir(&dir) + .output(), + ) + .await; + + match result { + Ok(Ok(output)) => Ok(output), + Ok(Err(e)) => Err(format!("Command failed: {}", e)), + Err(_) => Err(format!("Command timed out after {}s: {:?}", secs, args)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_format_size_bytes() { + assert_eq!(format_size(0), "0B"); + assert_eq!(format_size(512), "512.0B"); + } + + #[test] + fn test_format_size_kb() { + assert_eq!(format_size(1024), "1.0KB"); + assert_eq!(format_size(2048), "2.0KB"); + } + + #[test] + fn test_format_size_mb() { + assert_eq!(format_size(1048576), "1.0MB"); + } + + #[test] + fn test_format_size_gb() { + let one_gb = 1073741824u64; + assert_eq!(format_size(one_gb), "1.0GB"); + } + + #[test] + fn test_find_readme_nonexistent() { + let dir = TempDir::new().unwrap(); + assert!(find_readme(dir.path()).is_none()); + } + + #[test] + fn test_find_readme_exists() { + let dir = TempDir::new().unwrap(); + let readme_path = dir.path().join("README.md"); + std::fs::write(&readme_path, "# Test").unwrap(); + assert_eq!(find_readme(dir.path()), Some(readme_path)); + } + + #[test] + fn test_scan_configs_cargo() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap(); + let configs = scan_configs(dir.path()); + assert!(configs.contains(&"Cargo.toml".to_string())); + } + + #[test] + fn test_scan_configs_empty() { + let dir = TempDir::new().unwrap(); + let configs = scan_configs(dir.path()); + assert!(configs.is_empty()); + } + + #[test] + fn test_collect_entries_empty_dir() { + let dir = TempDir::new().unwrap(); + let entries = collect_entries(dir.path(), 0, 3, 100); + assert!(entries.is_empty()); + } + + #[test] + fn test_dir_size_file() { + let dir = TempDir::new().unwrap(); + let file = dir.path().join("test.txt"); + std::fs::write(&file, "hello").unwrap(); + let size = dir_size(&file, 0, 100).unwrap(); + assert_eq!(size, 5); + } + + #[test] + fn test_safe_path_nonexistent() { + let result = safe_path("/nonexistent/path/that/does/not/exist"); + assert!(result.is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 5da679d..f0aa167 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,14 @@ mod agents; mod config; +mod connector; mod engine; mod error; mod events; mod feedback; +mod fs_utils; mod mcp; mod profile; +mod terminal_writer; mod traits; mod types; @@ -15,82 +18,126 @@ use events::EventLogger; use mcp::McpServer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -fn check_updates() { - let project = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); +fn spawn_update_checker() { + let project = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); if !project.join(".git").exists() { return; } + tokio::spawn(async move { + let head_ref = match tokio::process::Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(&project) + .output() + .await + { + Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(), + Err(_) => return, + }; + if head_ref.is_empty() || head_ref == "HEAD" { + return; + } - let head_ref = std::process::Command::new("git") - .args(["symbolic-ref", "refs/remotes/origin/HEAD"]) - .current_dir(project) - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| { - s.trim() - .trim_start_matches("refs/remotes/origin/") - .to_string() - }) - .unwrap_or_else(|| "main".into()); - - let project_owned = project.to_path_buf(); - std::thread::spawn(move || { - let _ = std::process::Command::new("git") + let _ = tokio::process::Command::new("git") .args(["fetch", "--quiet"]) - .current_dir(&project_owned) - .output(); - }); + .current_dir(&project) + .output() + .await; + + let remote_ref = format!("HEAD..origin/{}", head_ref); + let behind = match tokio::process::Command::new("git") + .args(["rev-list", "--count", &remote_ref]) + .current_dir(&project) + .output() + .await + { + Ok(o) => String::from_utf8_lossy(&o.stdout) + .trim() + .parse::() + .unwrap_or(0), + Err(_) => 0, + }; - let behind = std::process::Command::new("git") - .args(["rev-list", "--count", &format!("HEAD..origin/{}", head_ref)]) - .current_dir(project) - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(0); + if behind > 0 { + tracing::warn!( + commits_behind = behind, + branch = head_ref, + "Update available. Run oura_update to upgrade." + ); + } + }); +} - if behind > 0 { - tracing::warn!( - commits_behind = behind, - "Update available. Run oura_update to upgrade." - ); +fn handle_cli_args() -> anyhow::Result<()> { + let args: Vec = std::env::args().collect(); + if args.len() == 1 { + return Ok(()); + } + match args[1].as_str() { + "--version" | "-v" => { + eprintln!("Oura v{}", env!("CARGO_PKG_VERSION")); + if let Ok(head) = std::env::var("GIT_HEAD") { + eprintln!("commit: {}", head); + } + if let Ok(ts) = std::env::var("BUILD_TIME") { + eprintln!("build: {}", ts); + } + std::process::exit(0); + } + "--init" => { + Config::init()?; + std::process::exit(0); + } + "--help" | "-h" => { + eprintln!("Oura v{}", env!("CARGO_PKG_VERSION")); + eprintln!("Usage: oura [--version | --init | --help]"); + eprintln!(); + eprintln!("Without arguments, runs the MCP server on stdio."); + eprintln!(" --version, -v Print version and exit"); + eprintln!(" --init Generate default config and exit"); + eprintln!(" --help, -h Show this help and exit"); + std::process::exit(0); + } + _ => { + eprintln!("Unknown argument: {}", args[1]); + eprintln!("Usage: oura [--version | --init | --help]"); + std::process::exit(1); + } } } #[tokio::main] async fn main() -> anyhow::Result<()> { + handle_cli_args()?; + + Config::init().ok(); + let config = Config::load(); + + let filter = + std::env::var("RUST_LOG").unwrap_or_else(|_| format!("oura={}", config.logging.level)); tracing_subscriber::registry() .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "oura=info".into()), + tracing_subscriber::EnvFilter::try_new(&filter).unwrap_or_else(|_| "oura=info".into()), ) .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr)) .init(); - Config::init().ok(); - let config = Config::load(); - - let quiet = std::env::var("OURA_QUIET").is_ok() || std::env::var("QUIET").is_ok(); + let quiet = std::env::var("OURA_QUIET").is_ok(); if !quiet { tracing::info!(version = env!("CARGO_PKG_VERSION"), "Oura ready (stdio)"); } - check_updates(); + spawn_update_checker(); - let mut engine = LoopEngine::new( - config.loop_engine.max_iterations, - config.loop_engine.convergence_threshold, - ); + let engine = LoopEngine::new(&config.loop_engine, &config.connector); let event_logger = EventLogger::new(engine.event_bus()); - tokio::spawn(async move { + let _logger_handle = tokio::spawn(async move { event_logger.run().await; }); let mut server = McpServer::new(engine); server.run().await?; + // engine.shutdown().await; // McpServer owns engine, cleanup on drop tracing::info!("Oura server shut down gracefully"); Ok(()) } diff --git a/src/mcp.rs b/src/mcp.rs deleted file mode 100644 index 1e838a7..0000000 --- a/src/mcp.rs +++ /dev/null @@ -1,1490 +0,0 @@ -use crate::agents::*; -use crate::engine::LoopEngine; -use crate::types::*; -use serde_json::{json, Value}; -use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - -static MCP_CALL_ID: AtomicU64 = AtomicU64::new(1); - -pub struct McpServer { - engine: LoopEngine, -} - -impl McpServer { - pub fn new(engine: LoopEngine) -> Self { - Self { engine } - } - - pub async fn run(&mut self) -> anyhow::Result<()> { - let stdin = tokio::io::stdin(); - let mut stdout = tokio::io::stdout(); - let mut reader = BufReader::new(stdin); - - loop { - let mut line = String::new(); - match reader.read_line(&mut line).await { - Ok(0) => break, - Ok(_) => { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - - let request: JsonRpcRequest = match serde_json::from_str(trimmed) { - Ok(req) => req, - Err(e) => { - tracing::error!(error = %e, message = %trimmed, "Parse error"); - let err_resp = json!({ - "jsonrpc": "2.0", - "id": null, - "error": { - "code": -32700, - "message": format!("Parse error: {}", e) - } - }); - if let Ok(output) = serde_json::to_string(&err_resp) { - let _ = stdout.write_all(output.as_bytes()).await; - let _ = stdout.write_all(b"\n").await; - let _ = stdout.flush().await; - } - continue; - } - }; - - let response = self.handle_request(&request).await; - - if response.id.is_null() { - continue; - } - - if let Ok(output) = serde_json::to_string(&response) { - let _ = stdout.write_all(output.as_bytes()).await; - let _ = stdout.write_all(b"\n").await; - let _ = stdout.flush().await; - } - } - Err(e) => { - tracing::error!(error = %e, "Failed to read line"); - break; - } - } - } - - Ok(()) - } - - async fn handle_request(&mut self, request: &JsonRpcRequest) -> JsonRpcResponse { - let id = request.id.clone(); - - match request.method.as_str() { - "initialize" => self.handle_initialize(id), - "initialized" => JsonRpcResponse { - jsonrpc: "2.0".into(), - id, - result: None, - error: None, - }, - "tools/list" => self.handle_tools_list(id), - "tools/call" => self.handle_tools_call(id, request.params.as_ref()).await, - "resources/list" => self.handle_resources_list(id), - "resources/read" => self.handle_resource_read(id, request.params.as_ref()), - "prompts/list" => self.handle_prompts_list(id), - "prompts/get" => self.handle_prompt_get(id, request.params.as_ref()), - _ => JsonRpcResponse { - jsonrpc: "2.0".into(), - id, - result: None, - error: Some(JsonRpcError { - code: -32601, - message: format!("Method not found: {}", request.method), - data: None, - }), - }, - } - } - - fn ok(&self, id: Value, result: Value) -> JsonRpcResponse { - JsonRpcResponse { - jsonrpc: "2.0".into(), - id, - result: Some(result), - error: None, - } - } - - fn err(&self, id: Value, code: i32, message: String) -> JsonRpcResponse { - JsonRpcResponse { - jsonrpc: "2.0".into(), - id, - result: None, - error: Some(JsonRpcError { - code, - message, - data: None, - }), - } - } - - fn text_content(text: String) -> Value { - json!([{ "type": "text", "text": text }]) - } - - fn handle_initialize(&self, id: Value) -> JsonRpcResponse { - self.ok( - id, - json!({ - "protocolVersion": "2024-11-05", - "capabilities": { - "tools": { "listChanged": true }, - "resources": { "listChanged": true }, - "prompts": { "listChanged": true } - }, - "serverInfo": { "name": "oura", "version": env!("CARGO_PKG_VERSION") } - }), - ) - } - - fn handle_tools_list(&self, id: Value) -> JsonRpcResponse { - let tools = vec![ - McpToolDefinition { - name: "oura_start_loop".into(), - description: "Start a new iteration loop to achieve a specific goal".into(), - input_schema: json!({ - "type": "object", - "properties": { - "goal": { "type": "string", "description": "The goal to achieve" }, - "maxIterations": { "type": "number", "description": "Maximum iterations (default: 20)" } - }, - "required": ["goal"] - }), - }, - McpToolDefinition { - name: "oura_iterate".into(), - description: "Execute a single iteration step manually".into(), - input_schema: json!({ "type": "object", "properties": {} }), - }, - McpToolDefinition { - name: "oura_loop_status".into(), - description: "Get current status of the active loop".into(), - input_schema: json!({ "type": "object", "properties": {} }), - }, - McpToolDefinition { - name: "oura_loop_stop".into(), - description: "Stop the currently running loop".into(), - input_schema: json!({ "type": "object", "properties": {} }), - }, - McpToolDefinition { - name: "oura_results".into(), - description: "Get accumulated results from all iterations".into(), - input_schema: json!({ - "type": "object", - "properties": { - "iteration": { "type": "number", "description": "Specific iteration to view (0 = all)" } - } - }), - }, - McpToolDefinition { - name: "oura_configure".into(), - description: "Update Oura engine configuration".into(), - input_schema: json!({ - "type": "object", - "properties": { - "maxIterations": { "type": "number", "description": "Maximum iterations" }, - "convergenceThreshold": { "type": "number", "description": "Score threshold (0-100)" }, - "workingDirectory": { "type": "string", "description": "Working directory" } - } - }), - }, - McpToolDefinition { - name: "oura_plugin_load".into(), - description: "Load a plugin from a directory path".into(), - input_schema: json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Path to plugin directory" } - }, - "required": ["path"] - }), - }, - McpToolDefinition { - name: "oura_plugin_list".into(), - description: "List all loaded plugins".into(), - input_schema: json!({ "type": "object", "properties": {} }), - }, - McpToolDefinition { - name: "oura_analyze_security".into(), - description: "Run security audit on specified files".into(), - input_schema: json!({ - "type": "object", - "properties": { - "files": { "type": "array", "items": { "type": "string" }, "description": "Files to scan" } - } - }), - }, - McpToolDefinition { - name: "oura_analyze_code".into(), - description: "Analyze code for clean code violations".into(), - input_schema: json!({ - "type": "object", - "properties": { - "files": { "type": "array", "items": { "type": "string" }, "description": "Files to analyze" } - } - }), - }, - McpToolDefinition { - name: "oura_check_integrity".into(), - description: "Check code integrity against baseline to detect lost critical symbols".into(), - input_schema: json!({ "type": "object", "properties": {} }), - }, - McpToolDefinition { - name: "oura_guard_destructive".into(), - description: "Check for dangerous destructive operations in code".into(), - input_schema: json!({ - "type": "object", - "properties": { - "files": { "type": "array", "items": { "type": "string" }, "description": "Files to scan" } - } - }), - }, - McpToolDefinition { - name: "oura_analyze_project".into(), - description: "Scan project directory structure, docs, configs and sizes. Token-efficient project analysis for organization decisions.".into(), - input_schema: json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Project root path (default: current dir)" }, - "depth": { "type": "number", "description": "Max directory depth (default: 4)", "default": 4 } - } - }), - }, - McpToolDefinition { - name: "oura_cleanup".into(), - description: "Scan and clean temp files, build artifacts, old logs. Safe dry-run by default.".into(), - input_schema: json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Root path to scan (default: current dir)" }, - "dry_run": { "type": "boolean", "description": "Only report, don't delete (default: true)", "default": true }, - "older_than_days": { "type": "number", "description": "Only touch files older than N days (default: 30)", "default": 30 }, - "patterns": { "type": "array", "items": { "type": "string" }, "description": "File patterns to match (e.g. *.tmp, *.log)" } - } - }), - }, - McpToolDefinition { - name: "mcp_call".into(), - description: "Call a tool on another MCP server. Lets sub-orchestrators dispatch to any MCP tool via HTTP.".into(), - input_schema: json!({ - "type": "object", - "properties": { - "server_url": { "type": "string", "description": "Base URL of target MCP server (e.g. http://localhost:7438)" }, - "tool_name": { "type": "string", "description": "Name of the tool to call" }, - "arguments": { "type": "object", "description": "Arguments for the tool", "default": {} }, - "endpoint": { "type": "string", "description": "Custom endpoint path (default: /message)", "default": "/message" } - }, - "required": ["server_url", "tool_name"] - }), - }, - McpToolDefinition { - name: "oura_version".into(), - description: "Show Oura version, build info, and latest available version.".into(), - input_schema: json!({ "type": "object", "properties": {} }), - }, - McpToolDefinition { - name: "oura_update".into(), - description: "Check for updates and optionally rebuild Oura from source via git pull + cargo build.".into(), - input_schema: json!({ - "type": "object", - "properties": { - "apply": { "type": "boolean", "description": "Actually pull and rebuild (default: false = dry-run check)", "default": false } - } - }), - }, - McpToolDefinition { - name: "oura_profile".into(), - description: "Detect project profile: indie, studio, enterprise, game-dev, ai-ml, etc. Lightweight analysis, no telemetry.".into(), - input_schema: json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Project path (default: current dir)" } - } - }), - }, - McpToolDefinition { - name: "oura_verify".into(), - description: "Verify dependency licenses, versions, and project profile. Checks Cargo.toml, package.json, Cargo.lock.".into(), - input_schema: json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Project path (default: current dir)" }, - "check_licenses": { "type": "boolean", "description": "Check dependency licenses (default: true)", "default": true }, - "check_versions": { "type": "boolean", "description": "Check version stability (default: true)", "default": true } - } - }), - }, - McpToolDefinition { - name: "oura_working_dir".into(), - description: "Set the working directory for Oura commands (test, clippy, project analysis).".into(), - input_schema: json!({ - "type": "object", - "properties": { - "directory": { "type": "string", "description": "Absolute path to the working directory" } - }, - "required": ["directory"] - }), - }, - ]; - - self.ok(id, json!({ "tools": tools })) - } - - async fn handle_tools_call(&mut self, id: Value, params: Option<&Value>) -> JsonRpcResponse { - let params = match params { - Some(p) => p, - None => return self.err(id, -32602, "Missing params".into()), - }; - - let name = params["name"].as_str().unwrap_or(""); - let args = params.get("arguments").cloned().unwrap_or(json!({})); - - match name { - "oura_start_loop" => self.cmd_start_loop(id, &args).await, - "oura_iterate" => self.cmd_iterate(id).await, - "oura_loop_status" => self.cmd_loop_status(id), - "oura_loop_stop" => self.cmd_loop_stop(id), - "oura_results" => self.cmd_results(id, &args), - "oura_configure" => self.cmd_configure(id, &args), - "oura_plugin_load" => self.cmd_plugin_load(id, &args), - "oura_plugin_list" => self.cmd_plugin_list(id), - "oura_analyze_security" => self.cmd_analyze_security(id, &args), - "oura_analyze_code" => self.cmd_analyze_code(id, &args), - "oura_check_integrity" => self.cmd_check_integrity(id), - "oura_guard_destructive" => self.cmd_guard_destructive(id, &args), - "oura_analyze_project" => self.cmd_analyze_project(id, &args), - "oura_cleanup" => self.cmd_cleanup(id, &args), - "mcp_call" => self.cmd_mcp_call(id, &args), - "oura_version" => self.cmd_version(id), - "oura_update" => self.cmd_update(id, &args), - "oura_profile" => self.cmd_profile(id, &args), - "oura_verify" => self.cmd_verify(id, &args), - "oura_working_dir" => self.cmd_working_dir(id, &args), - _ => self.err(id, -32602, format!("Unknown tool: {}", name)), - } - } - - async fn cmd_start_loop(&mut self, id: Value, args: &Value) -> JsonRpcResponse { - let goal = args["goal"].as_str().unwrap_or("improve codebase"); - let max_iter = args["maxIterations"].as_u64().unwrap_or(20) as u32; - - self.engine.update_max_iterations(max_iter); - match self.engine.start(goal).await { - Ok(state) => { - let msg = format!( - "Loop started: {}\nLoop ID: {}\nStatus: {}", - goal, state.id, state.status - ); - self.ok(id, json!({ "content": Self::text_content(msg) })) - } - Err(e) => self.err(id, e.code(), format!("Failed to start loop: {}", e)), - } - } - - async fn cmd_iterate(&mut self, id: Value) -> JsonRpcResponse { - match self.engine.iterate().await { - Ok(result) => { - let errors = result - .feedback - .iter() - .filter(|f| f.type_ == "error") - .count(); - let warnings = result - .feedback - .iter() - .filter(|f| f.type_ == "warning") - .count(); - let done = result.actions.iter().filter(|a| a.status == "done").count(); - let failed_actions = result - .actions - .iter() - .filter(|a| a.status == "error") - .count(); - - let mut msg = format!( - "Iteration #{} - Score: {:.1}/100\nActions: {} done, {} errors\nFeedback: {} errors, {} warnings", - result.iteration, result.score, done, failed_actions, errors, warnings - ); - - if result.status == "converged" { - msg.push_str("\n\n*** CONVERGED ***"); - } else if result.status == "failed" { - msg.push_str("\n\n*** FAILED ***"); - } - - self.ok(id, json!({ "content": Self::text_content(msg) })) - } - Err(e) => self.err(id, e.code(), format!("Iteration failed: {}", e)), - } - } - - fn cmd_loop_status(&self, id: Value) -> JsonRpcResponse { - let state = self.engine.get_state(); - match state { - Some(state) => { - let last = state.history.last(); - let mut lines = vec![ - format!("Status: {}", state.status), - format!("Goal: {}", state.goal), - format!("Loop ID: {}", state.id), - format!("Current Iteration: {}", state.current_iteration), - format!("Started: {}", state.start_time), - ]; - if let Some(last) = last { - lines.push(format!( - "Last Iteration: #{} (score: {:.1}/100)", - last.iteration, last.score - )); - } - self.ok( - id, - json!({ "content": Self::text_content(lines.join("\n")) }), - ) - } - None => self.ok( - id, - json!({ "content": Self::text_content("No active loop".into()) }), - ), - } - } - - fn cmd_loop_stop(&mut self, id: Value) -> JsonRpcResponse { - let iter = self.engine.stop(); - self.ok(id, json!({ "content": Self::text_content(format!("Loop stopped after {} iterations", iter)) })) - } - - fn cmd_results(&self, id: Value, args: &Value) -> JsonRpcResponse { - let results = self.engine.get_results(); - let iter_filter = args["iteration"].as_u64().unwrap_or(0); - let max_iterations = 20; - - let filtered: Vec<&IterationResult> = if iter_filter > 0 { - results - .iter() - .filter(|r| r.iteration == iter_filter as u32) - .collect() - } else { - results.iter().rev().take(max_iterations).collect() - }; - - let summary: Vec = filtered - .iter() - .map(|r| { - json!({ - "iteration": r.iteration, - "score": r.score, - "status": r.status, - "actions": r.actions.iter().map(|a| json!({ - "agent": a.agent, - "type": a.type_, - "status": a.status - })).collect::>(), - "feedback": r.feedback.iter().map(|f| json!({ - "source": f.source, - "type": f.type_, - "message": f.message - })).collect::>(), - }) - }) - .collect(); - - self.ok(id, json!({ "content": Self::text_content(serde_json::to_string_pretty(&summary).unwrap_or_default()) })) - } - - fn cmd_configure(&mut self, id: Value, args: &Value) -> JsonRpcResponse { - if let Some(max) = args["maxIterations"].as_u64() { - self.engine.update_max_iterations(max as u32); - } - if let Some(threshold) = args["convergenceThreshold"].as_f64() { - self.engine.update_convergence_threshold(threshold); - } - if let Some(dir) = args["workingDirectory"].as_str() { - let path = std::path::Path::new(dir); - if path.exists() { - std::env::set_current_dir(path).ok(); - } - } - self.ok( - id, - json!({ "content": Self::text_content("Configuration updated".into()) }), - ) - } - - fn cmd_working_dir(&self, id: Value, args: &Value) -> JsonRpcResponse { - let dir = args["directory"].as_str().unwrap_or("."); - let path = std::path::Path::new(dir); - if !path.exists() { - return self.err(id, -32602, format!("Directory not found: {}", dir)); - } - std::env::set_current_dir(path).ok(); - self.ok( - id, - json!({ "content": Self::text_content(format!("Working directory set to: {}", dir)) }), - ) - } - - fn cmd_plugin_load(&mut self, id: Value, _args: &Value) -> JsonRpcResponse { - self.ok(id, json!({ "content": Self::text_content("Plugin loading not yet implemented in Rust version".into()) })) - } - - fn cmd_plugin_list(&self, id: Value) -> JsonRpcResponse { - self.ok( - id, - json!({ "content": Self::text_content("No plugins loaded".into()) }), - ) - } - - fn cmd_analyze_security(&self, id: Value, args: &Value) -> JsonRpcResponse { - let files: Vec = args["files"] - .as_array() - .map(|a| { - a.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - - let auditor = SecurityAuditor::new(); - let findings = auditor.audit(&files); - - if findings.is_empty() { - return self.ok(id, json!({ "content": Self::text_content("Security scan complete: no vulnerabilities found".into()) })); - } - - let critical = findings.iter().filter(|f| f.severity == "critical").count(); - let high = findings.iter().filter(|f| f.severity == "high").count(); - let medium = findings.iter().filter(|f| f.severity == "medium").count(); - let low = findings.iter().filter(|f| f.severity == "low").count(); - - let mut report = format!( - "Security scan complete: {} issues found\n Critical: {}\n High: {}\n Medium: {}\n Low: {}\n", - findings.len(), critical, high, medium, low - ); - - for f in &findings { - report.push_str(&format!( - "\n[{}] {}:{} - {}\n => {}", - f.severity.to_uppercase(), - f.file, - f.line.map(|l| l.to_string()).unwrap_or_else(|| "?".into()), - f.description, - f.recommendation - )); - } - - self.ok(id, json!({ "content": Self::text_content(report) })) - } - - fn cmd_analyze_code(&self, id: Value, args: &Value) -> JsonRpcResponse { - let files: Vec = args["files"] - .as_array() - .map(|a| { - a.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - - let refactor = RefactorEngine::new(); - let mut report = String::from("Code Analysis Results:\n"); - - for file in &files { - let (issues, suggestions) = refactor.analyze(file); - if !issues.is_empty() { - report.push_str(&format!("\n{}:", file)); - for issue in &issues { - report.push_str(&format!("\n [ISSUE] {}", issue)); - } - for suggestion in &suggestions { - report.push_str(&format!("\n [SUGGEST] {}", suggestion)); - } - } - } - - if report == "Code Analysis Results:\n" { - report = "Code analysis passed: no clean code violations detected".into(); - } - - self.ok(id, json!({ "content": Self::text_content(report) })) - } - - fn cmd_check_integrity(&self, id: Value) -> JsonRpcResponse { - let guard = AntiDeletionGuard::new(); - match guard.check_integrity() { - Ok(msg) => self.ok(id, json!({ "content": Self::text_content(msg) })), - Err(msg) => self.ok(id, json!({ "content": Self::text_content(msg) })), - } - } - - fn cmd_guard_destructive(&self, id: Value, _args: &Value) -> JsonRpcResponse { - self.ok( - id, - json!({ - "content": Self::text_content( - "Destructive query guard active: all DROP/DELETE/TRUNCATE/ALTER operations \ - require explicit confirmation with backup verification".into()) - }), - ) - } - - fn cmd_analyze_project(&self, id: Value, args: &Value) -> JsonRpcResponse { - let root = args["path"].as_str().unwrap_or("."); - let max_depth = args["depth"].as_u64().unwrap_or(4) as usize; - - let root_path = Path::new(root); - if !root_path.exists() { - return self.err(id, -32602, format!("Path not found: {}", root)); - } - - let mut report = String::new(); - report.push_str(&format!( - "[{}] ({})\n", - root_path - .file_name() - .map(|n| n.to_string_lossy()) - .unwrap_or_else(|| "?".into()), - root - )); - report.push_str(&format!( - " Size: {}\n", - format_size(dir_size(root_path).unwrap_or(0)) - )); - - let readme = find_readme(root_path); - if let Some(rm) = readme { - if let Ok(content) = std::fs::read_to_string(&rm) { - let preview: String = content.lines().take(8).collect::>().join("\n"); - report.push_str(&format!( - " README: {} chars\n{}\n", - content.len(), - preview - )); - } - } - - let docs_dir = root_path.join("docs"); - if docs_dir.exists() && docs_dir.is_dir() { - let count = std::fs::read_dir(&docs_dir).map(|e| e.count()).unwrap_or(0); - report.push_str(&format!(" docs/: {} files\n", count)); - } - - let configs = scan_configs(root_path); - if !configs.is_empty() { - report.push_str(&format!(" Config: {}\n", configs.join(", "))); - } - - let mut entries = collect_entries(root_path, 0, max_depth); - entries.sort_by_key(|a| a.1); - for (path_str, depth, is_dir, size) in &entries { - if *depth == 0 || *depth > max_depth { - continue; - } - let indent = " ".repeat(*depth); - let marker = if *is_dir { "+" } else { " " }; - let size_str = if *is_dir { - String::new() - } else { - format!(" ({})", format_size(*size)) - }; - report.push_str(&format!("{}{} {}{}\n", indent, marker, path_str, size_str)); - } - - let large: Vec<_> = entries - .iter() - .filter(|(_, _, is_dir, size)| !is_dir && *size > 100_000) - .collect(); - if !large.is_empty() { - report.push_str(&format!("\n Large files (>100KB): {}\n", large.len())); - for (path_str, _, _, size) in large.iter().take(5) { - report.push_str(&format!(" - {} ({})\n", path_str, format_size(*size))); - } - } - - let total_files = entries.iter().filter(|(_, _, is_dir, _)| !is_dir).count(); - let total_dirs = entries.iter().filter(|(_, _, is_dir, _)| *is_dir).count(); - report.push_str(&format!( - "\n Summary: {} dirs, {} files", - total_dirs, total_files - )); - - self.ok(id, json!({ "content": Self::text_content(report) })) - } - - fn cmd_mcp_call(&self, id: Value, args: &Value) -> JsonRpcResponse { - let server_url = args["server_url"] - .as_str() - .unwrap_or("") - .trim_end_matches('/'); - let tool_name = args["tool_name"].as_str().unwrap_or(""); - let tool_args = args.get("arguments").cloned().unwrap_or(json!({})); - let endpoint = args["endpoint"].as_str().unwrap_or("/message"); - - if server_url.is_empty() || tool_name.is_empty() { - return self.err(id, -32602, "server_url and tool_name are required".into()); - } - - let call_id = MCP_CALL_ID.fetch_add(1, Ordering::SeqCst); - let request_body = json!({ - "jsonrpc": "2.0", - "id": call_id, - "method": "tools/call", - "params": { - "name": tool_name, - "arguments": tool_args - } - }); - - let url = format!("{}{}", server_url, endpoint); - match Self::http_post(&url, &request_body) { - Ok(response_text) => { - self.ok(id, json!({ "content": Self::text_content(response_text) })) - } - Err(e) => self.ok( - id, - json!({ "content": Self::text_content(format!("MCP call failed: {}", e)) }), - ), - } - } - - fn http_post(url: &str, body: &Value) -> Result { - let (tx, rx) = std::sync::mpsc::channel(); - let url = url.to_string(); - let body = body.clone(); - std::thread::spawn(move || { - let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| e.to_string()); - match client { - Ok(c) => { - let resp = c.post(&url).json(&body).send().map_err(|e| e.to_string()); - match resp { - Ok(r) => { - let _ = tx.send(r.text().map_err(|e| e.to_string())); - } - Err(e) => { - let _ = tx.send(Err(e)); - } - } - } - Err(e) => { - let _ = tx.send(Err(e)); - } - } - }); - rx.recv_timeout(std::time::Duration::from_secs(35)) - .map_err(|_| "HTTP request timed out".to_string())? - } - - fn cmd_version(&self, id: Value) -> JsonRpcResponse { - let version = env!("CARGO_PKG_VERSION"); - - let build_ts = option_env!("BUILD_TIME").unwrap_or("0"); - let build_date = { - let secs: u64 = build_ts.parse().unwrap_or(0); - if secs > 0 { - let naive = chrono::DateTime::from_timestamp(secs as i64, 0) - .map(|dt| dt.format("%Y-%m").to_string()) - .unwrap_or_else(|| "unknown".to_string()); - naive - } else { - "unknown".to_string() - } - }; - let git_head = option_env!("GIT_HEAD").unwrap_or("unknown"); - let project_dir = env!("CARGO_MANIFEST_DIR"); - - let out = format!( - "Oura v{}\nBuilt: {}\nCommit: {}\nProject: {}", - version, build_date, git_head, project_dir - ); - - self.ok(id, json!({ "content": Self::text_content(out) })) - } - - fn cmd_update(&self, id: Value, args: &Value) -> JsonRpcResponse { - let apply = args["apply"].as_bool().unwrap_or(false); - let project_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - - if !project_dir.join(".git").exists() { - return self.ok( - id, - json!({ - "content": Self::text_content("Not a git repository. Can't auto-update.".into()) - }), - ); - } - - // Detect default branch - let default_branch = std::process::Command::new("git") - .args(["symbolic-ref", "refs/remotes/origin/HEAD"]) - .current_dir(project_dir) - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| { - s.trim() - .trim_start_matches("refs/remotes/origin/") - .to_string() - }) - .unwrap_or_else(|| "main".into()); - - let mut report = String::new(); - report.push_str(&format!( - "Oura v{} - checking for updates...\n", - env!("CARGO_PKG_VERSION") - )); - - let git_fetch = run_command_timeout(&["git", "fetch", "--quiet"], project_dir, 30); - - match git_fetch { - Ok(output) if output.status.success() => { - report.push_str("Git fetch OK.\n"); - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - report.push_str(&format!("Git fetch: {}\n", stderr)); - return self.ok(id, json!({ "content": Self::text_content(report) })); - } - Err(e) => { - report.push_str(&format!("Git fetch failed: {}\n", e)); - return self.ok(id, json!({ "content": Self::text_content(report) })); - } - } - - let remote_ref = format!("HEAD..origin/{}", default_branch); - let ahead = std::process::Command::new("git") - .args(["rev-list", "--count", &remote_ref]) - .current_dir(project_dir) - .output(); - - let commits_behind = match ahead { - Ok(output) => String::from_utf8_lossy(&output.stdout).trim().to_string(), - Err(_) => "?".to_string(), - }; - - let local_hash = std::process::Command::new("git") - .args(["rev-parse", "--short", "HEAD"]) - .current_dir(project_dir) - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) - .unwrap_or_else(|| "?".into()); - - let remote_hash = std::process::Command::new("git") - .args([ - "rev-parse", - "--short", - &format!("origin/{}", default_branch), - ]) - .current_dir(project_dir) - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) - .unwrap_or_else(|| "?".into()); - - report.push_str(&format!( - "Local: v{} @{}\nRemote: @{}\nBehind: {} commits\n", - env!("CARGO_PKG_VERSION"), - local_hash, - remote_hash, - commits_behind - )); - - let behind_count: i32 = commits_behind.parse().unwrap_or(0); - - if behind_count <= 0 { - report.push_str("\nAlready up to date. No update needed."); - } else if apply { - let pull = run_command_timeout(&["git", "pull", "--rebase"], project_dir, 60); - - match pull { - Ok(output) if output.status.success() => { - report.push_str("\nGit pull OK. Rebuilding..."); - let build = - run_command_timeout(&["cargo", "build", "--release"], project_dir, 300); - - match build { - Ok(b) if b.status.success() => { - report.push_str(" Build OK! Restart Oura to use the new version."); - } - Ok(b) => { - let stderr = String::from_utf8_lossy(&b.stderr); - report.push_str(&format!(" Build failed:\n{}", stderr)); - } - Err(e) => { - report.push_str(&format!(" Build error: {}", e)); - } - } - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - report.push_str(&format!("\nGit pull failed:\n{}", stderr)); - } - Err(e) => { - report.push_str(&format!("\nGit pull error: {}", e)); - } - } - } else { - report.push_str(&format!( - "\n{} commits behind. Run oura_update with apply=true to update.", - behind_count - )); - } - - self.ok(id, json!({ "content": Self::text_content(report) })) - } - - fn cmd_profile(&self, id: Value, args: &Value) -> JsonRpcResponse { - let root = args["path"] - .as_str() - .map(Path::new) - .unwrap_or_else(|| std::path::Path::new(".")); - let profile = crate::profile::ProjectProfile::detect(root); - let summary = profile.summary(); - - let json_output = serde_json::json!({ - "user_type": profile.user_type, - "confidence": profile.confidence, - "ecosystem": profile.ecosystem, - "has_game_engine": profile.has_game_engine, - "has_paid_tools": profile.has_paid_tools, - "has_enterprise_configs": profile.has_enterprise_configs, - "dependency_count": profile.dependency_count, - "indicators": profile.indicators, - }); - - let report = format!( - "{}\n\n---\n{}", - summary, - serde_json::to_string_pretty(&json_output).unwrap_or_default() - ); - self.ok(id, json!({ "content": Self::text_content(report) })) - } - - fn cmd_verify(&self, id: Value, args: &Value) -> JsonRpcResponse { - let root = args["path"] - .as_str() - .map(Path::new) - .unwrap_or_else(|| std::path::Path::new(".")); - let report = crate::profile::verify_dependencies(root); - self.ok( - id, - json!({ "content": Self::text_content(report.summary()) }), - ) - } - - fn handle_resources_list(&self, id: Value) -> JsonRpcResponse { - self.ok(id, json!({ - "resources": [ - { "uri": "oura://state", "name": "Oura Loop State", "description": "Current state of the active loop", "mimeType": "application/json" }, - { "uri": "oura://results", "name": "Oura Iteration Results", "description": "History of all iterations", "mimeType": "application/json" }, - { "uri": "oura://config", "name": "Oura Configuration", "description": "Current engine configuration", "mimeType": "application/json" }, - ] - })) - } - - fn handle_resource_read(&self, id: Value, params: Option<&Value>) -> JsonRpcResponse { - let uri = params.and_then(|p| p["uri"].as_str()).unwrap_or(""); - let state = self.engine.get_state(); - - let content = match uri { - "oura://state" => serde_json::to_string_pretty(&state).unwrap_or_default(), - "oura://results" => { - serde_json::to_string_pretty(&state.map(|s| s.history.clone()).unwrap_or_default()) - .unwrap_or_default() - } - "oura://config" => { - let max_iter = self.engine.max_iterations(); - let threshold = self.engine.convergence_threshold(); - let cwd = std::env::current_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default(); - let config_json = json!({ - "max_iterations": max_iter, - "convergence_threshold": threshold, - "working_directory": cwd, - }); - serde_json::to_string_pretty(&config_json).unwrap_or_default() - } - _ => return self.err(id, -32602, format!("Unknown resource: {}", uri)), - }; - - self.ok( - id, - json!({ - "contents": [{ - "uri": uri, - "mimeType": "application/json", - "text": content - }] - }), - ) - } - - fn handle_prompts_list(&self, id: Value) -> JsonRpcResponse { - self.ok(id, json!({ - "prompts": [ - { - "name": "start_loop", - "description": "Template for starting a new Oura iteration loop", - "arguments": [ - { "name": "goal", "description": "What to achieve", "required": true } - ] - }, - { - "name": "loop_summary", - "description": "Template for summarizing loop results", - "arguments": [ - { "name": "iteration", "description": "Which iteration to summarize", "required": false } - ] - } - ] - })) - } - - fn handle_prompt_get(&self, id: Value, params: Option<&Value>) -> JsonRpcResponse { - let name = params.and_then(|p| p["name"].as_str()).unwrap_or(""); - match name { - "start_loop" => { - let goal = params.and_then(|p| p["arguments"].as_object()) - .and_then(|a| a.get("goal")) - .and_then(|v| v.as_str()) - .unwrap_or("improve codebase"); - self.ok(id, json!({ - "messages": [{ - "role": "user", - "content": { - "type": "text", - "text": format!( - "I want to start an Oura iteration loop to: {}\n\n\ - Please call oura_start_loop with goal=\"{}\".\n\ - Oura will run iterations until convergence or max iterations reached.\n\ - You can check progress with oura_loop_status and finally oura_results.", - goal, goal - ) - } - }] - })) - } - "loop_summary" => { - self.ok(id, json!({ - "messages": [{ - "role": "user", - "content": { - "type": "text", - "text": String::from("Please summarize the Oura loop results. Use oura_results to get the data first.") - } - }] - })) - } - _ => self.err(id, -32602, format!("Unknown prompt: {}", name)), - } - } - - fn cmd_cleanup(&self, id: Value, args: &Value) -> JsonRpcResponse { - let root = args["path"].as_str().unwrap_or("."); - let dry_run = args["dry_run"].as_bool().unwrap_or(true); - let older_than = args["older_than_days"].as_u64().unwrap_or(30); - let patterns: Vec = args["patterns"] - .as_array() - .map(|a| { - a.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_else(|| { - vec![ - "*.tmp".into(), - "*.temp".into(), - "*.log".into(), - "*.bak".into(), - "*.swp".into(), - "*.swo".into(), - "*.pyc".into(), - "__pycache__".into(), - ".DS_Store".into(), - "Thumbs.db".into(), - ] - }); - let dir_patterns = ["node_modules", ".next", ".turbo", "dist", "build", ".cache"]; - - let root_path = Path::new(root); - if !root_path.exists() { - return self.err(id, -32602, format!("Path not found: {}", root)); - } - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let max_age = if older_than > 0 { - older_than * 86400 - } else { - u64::MAX - }; - - let mut report = String::new(); - - let mut ctx = CleanupContext { - patterns, - dir_patterns: dir_patterns.to_vec(), - candidates: Vec::new(), - total_size: 0, - now, - max_age, - max_depth: 10, - }; - ctx.walk(root_path, 0); - - let candidates = ctx.candidates; - let total_size = ctx.total_size; - - if candidates.is_empty() { - report = format!( - "No cleanup candidates found in {}. Everything looks clean.", - root - ); - } else { - report.push_str(&format!("Cleanup candidates in {}:\n", root)); - for (path, reason) in &candidates { - report.push_str(&format!(" - {} ({})\n", path, reason)); - } - report.push_str(&format!( - "\nTotal: {} items, {}\n", - candidates.len(), - format_size(total_size) - )); - - if !dry_run { - let mut deleted = 0; - let mut failed = 0; - for (path, _) in &candidates { - let p = Path::new(path); - if p.is_dir() { - match std::fs::remove_dir_all(p) { - Ok(_) => deleted += 1, - Err(e) => { - report.push_str(&format!(" FAILED: {}: {}\n", path, e)); - failed += 1; - } - } - } else { - match std::fs::remove_file(p) { - Ok(_) => deleted += 1, - Err(e) => { - report.push_str(&format!(" FAILED: {}: {}\n", path, e)); - failed += 1; - } - } - } - } - report.push_str(&format!("\nDeleted: {}, Failed: {}", deleted, failed)); - } else { - report.push_str("\nDry-run mode. Set dry_run=false to delete."); - } - } - - self.ok(id, json!({ "content": Self::text_content(report) })) - } -} - -fn run_command_timeout( - args: &[&str], - dir: &Path, - secs: u64, -) -> std::result::Result { - let (tx, rx) = std::sync::mpsc::channel(); - let dir = dir.to_path_buf(); - let args_owned: Vec = args.iter().map(|s| s.to_string()).collect(); - std::thread::spawn(move || { - let _ = tx.send( - std::process::Command::new(&args_owned[0]) - .args(&args_owned[1..]) - .current_dir(&dir) - .output(), - ); - }); - match rx.recv_timeout(std::time::Duration::from_secs(secs)) { - Ok(result) => result.map_err(|e| format!("Command failed: {}", e)), - Err(_) => Err("Command timed out".to_string()), - } -} - -fn dir_size(path: &Path) -> std::io::Result { - let mut total = 0u64; - if path.is_file() { - return Ok(path.metadata()?.len()); - } - walk_size(path, 0, 3, &mut total)?; - Ok(total) -} - -fn walk_size(path: &Path, depth: usize, max_depth: usize, total: &mut u64) -> std::io::Result<()> { - if depth > max_depth { - return Ok(()); - } - if path.is_dir() { - let mut visited = std::collections::HashSet::new(); - walk_size_inner(path, depth, max_depth, total, &mut visited) - } else { - Ok(()) - } -} - -fn walk_size_inner( - path: &Path, - depth: usize, - max_depth: usize, - total: &mut u64, - visited: &mut std::collections::HashSet, -) -> std::io::Result<()> { - if depth > max_depth { - return Ok(()); - } - if let Ok(meta) = path.metadata() { - #[cfg(unix)] - let ino = std::os::unix::fs::MetadataExt::ino(&meta); - #[cfg(not(unix))] - let ino = 0u64; - if ino != 0 && !visited.insert(ino) { - return Ok(()); - } - } - if path.is_dir() { - for entry in std::fs::read_dir(path)? { - let e = entry?; - let p = e.path(); - if p.is_symlink() { - continue; - } - if p.is_file() { - *total += e.metadata()?.len(); - } else if p.is_dir() { - walk_size_inner(&p, depth + 1, max_depth, total, visited)?; - } - } - } - Ok(()) -} - -fn format_size(bytes: u64) -> String { - const UNITS: &[&str] = &["B", "KB", "MB", "GB"]; - if bytes == 0 { - return "0B".into(); - } - let mut size = bytes as f64; - let mut unit = 0; - while size >= 1024.0 && unit < UNITS.len() - 1 { - size /= 1024.0; - unit += 1; - } - format!("{:.1}{}", size, UNITS[unit]) -} - -fn find_readme(path: &Path) -> Option { - let names = [ - "README.md", - "Readme.md", - "readme.md", - "README", - "LEEME.md", - "README.txt", - ]; - for name in &names { - let p = path.join(name); - if p.exists() { - return Some(p); - } - } - None -} - -fn scan_configs(path: &Path) -> Vec { - let configs = [ - "Cargo.toml", - "package.json", - "pyproject.toml", - "go.mod", - "CMakeLists.txt", - "Makefile", - "Dockerfile", - "docker-compose.yml", - "compose.yaml", - ".env.example", - ".gitignore", - ".editorconfig", - "tsconfig.json", - "opencode.json", - "opencode.jsonc", - "claude-code.json", - "cursor.json", - ]; - let mut found = Vec::new(); - for name in &configs { - if path.join(name).exists() { - found.push(name.to_string()); - } - } - found -} - -fn collect_entries(path: &Path, depth: usize, max_depth: usize) -> Vec<(String, usize, bool, u64)> { - let mut entries = Vec::new(); - let mut visited = std::collections::HashSet::new(); - collect_entries_inner(path, depth, max_depth, &mut entries, &mut visited); - entries -} - -fn collect_entries_inner( - path: &Path, - depth: usize, - max_depth: usize, - entries: &mut Vec<(String, usize, bool, u64)>, - visited: &mut std::collections::HashSet, -) { - if depth > max_depth || !path.is_dir() { - return; - } - if let Ok(meta) = path.metadata() { - #[cfg(unix)] - let ino = std::os::unix::fs::MetadataExt::ino(&meta); - #[cfg(not(unix))] - let ino = 0u64; - if ino != 0 && !visited.insert(ino) { - return; - } - } - if let Ok(readdir) = std::fs::read_dir(path) { - for entry in readdir.flatten() { - let p = entry.path(); - if p.is_symlink() { - continue; - } - let name = p - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - if name.starts_with('.') { - continue; - } - let is_dir = p.is_dir(); - let size = if is_dir { - 0 - } else { - p.metadata().map(|m| m.len()).unwrap_or(0) - }; - entries.push((name.clone(), depth, is_dir, size)); - if is_dir { - collect_entries_inner(&p, depth + 1, max_depth, entries, visited); - } - } - } -} - -struct CleanupContext { - patterns: Vec, - dir_patterns: Vec<&'static str>, - candidates: Vec<(String, String)>, - total_size: u64, - now: u64, - max_age: u64, - max_depth: usize, -} - -impl CleanupContext { - fn walk(&mut self, path: &Path, depth: usize) { - let mut visited = std::collections::HashSet::new(); - self.walk_inner(path, depth, &mut visited); - } - - fn walk_inner( - &mut self, - path: &Path, - depth: usize, - visited: &mut std::collections::HashSet, - ) { - if depth > self.max_depth || !path.is_dir() { - return; - } - if let Ok(meta) = path.metadata() { - #[cfg(unix)] - let ino = std::os::unix::fs::MetadataExt::ino(&meta); - #[cfg(not(unix))] - let ino = 0u64; - if ino != 0 && !visited.insert(ino) { - return; - } - } - if let Ok(readdir) = std::fs::read_dir(path) { - for entry in readdir.flatten() { - let p = entry.path(); - if p.is_symlink() { - continue; - } - let name = p - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - if name.starts_with('.') { - continue; - } - - if p.is_dir() { - if self.dir_patterns.contains(&name.as_str()) { - let size = dir_size(&p).unwrap_or(0); - self.total_size += size; - self.candidates - .push((p.to_string_lossy().to_string(), format!("{} dir", name))); - } else { - self.walk_inner(&p, depth + 1, visited); - } - } else { - let matched = self.patterns.iter().any(|pat| { - if let Some(ext) = pat.strip_prefix('*') { - name.ends_with(ext) - } else { - name == *pat - } - }); - if matched { - let size = p.metadata().map(|m| m.len()).unwrap_or(0); - self.total_size += size; - let reason = if let Ok(meta) = p.metadata() { - if let Ok(modified) = meta.modified() { - if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) - { - let age_secs = self.now.saturating_sub(duration.as_secs()); - if age_secs > self.max_age { - format!("old ({} days)", age_secs / 86400) - } else { - "temp file".into() - } - } else { - "temp file".into() - } - } else { - "temp file".into() - } - } else { - "temp file".into() - }; - self.candidates - .push((p.to_string_lossy().to_string(), reason)); - } - } - } - } - } -} diff --git a/src/mcp/handlers.rs b/src/mcp/handlers.rs new file mode 100644 index 0000000..1db96d1 --- /dev/null +++ b/src/mcp/handlers.rs @@ -0,0 +1,792 @@ +use crate::agents::*; +use crate::fs_utils::{self, run_command_timeout, safe_path, CleanupContext}; +use crate::mcp::McpServer; +use crate::types::*; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; + +impl McpServer { + pub(super) async fn cmd_start_loop(&mut self, id: Value, args: &Value) -> JsonRpcResponse { + let goal = match args["goal"].as_str() { + Some(g) => g, + None => return self.err(id, -32602, "Missing required parameter: goal".into()), + }; + let max_iter = args["maxIterations"].as_u64().map(|n| n as u32); + let manual = args["manual"].as_bool().unwrap_or(false); + + match self.engine.start(goal, manual, max_iter).await { + Ok(state) => { + let mode = if manual { "manual" } else { "autonomous" }; + let msg = format!( + "Loop started in {} mode: {}\nLoop ID: {}\nStatus: {}", + mode, goal, state.id, state.status + ); + self.ok(id, json!({ "content": Self::text_content(msg) })) + } + Err(e) => self.err(id, e.code(), format!("Failed to start loop: {}", e)), + } + } + + pub(super) async fn cmd_iterate(&mut self, id: Value) -> JsonRpcResponse { + match self.engine.iterate().await { + Ok(result) => { + let errors = result + .feedback + .iter() + .filter(|f| f.type_ == "error") + .count(); + let warnings = result + .feedback + .iter() + .filter(|f| f.type_ == "warning") + .count(); + + let mut msg = format!( + "Iteration #{} - Score: {:.1}/100\nFeedback: {} errors, {} warnings", + result.iteration, result.score, errors, warnings + ); + for f in &result.feedback { + msg.push_str(&format!("\n [{}] {}: {}", f.type_, f.source, f.message)); + } + if result.status == "completed" { + msg.push_str("\n\n*** CONVERGED ***"); + } else if result.status == "failed" { + msg.push_str("\n\n*** FAILED ***"); + } + + self.ok(id, json!({ "content": Self::text_content(msg) })) + } + Err(e) => self.err(id, e.code(), format!("Iteration failed: {}", e)), + } + } + + pub(super) fn cmd_loop_status(&self, id: Value) -> JsonRpcResponse { + let state = match self.engine.get_state() { + Ok(s) => s, + Err(e) => return self.err(id, e.code(), format!("Failed to get state: {}", e)), + }; + match state { + Some(state) => { + let last = state.history.last(); + let mut lines = vec![ + format!("Status: {}", state.status), + format!("Goal: {}", state.goal), + format!("Loop ID: {}", state.id), + format!("Current Iteration: {}", state.current_iteration), + format!("Started: {}", state.start_time), + ]; + if let Some(last) = last { + lines.push(format!( + "Last Iteration: #{} (score: {:.1}/100)", + last.iteration, last.score + )); + } + self.ok( + id, + json!({ "content": Self::text_content(lines.join("\n")) }), + ) + } + None => self.ok( + id, + json!({ "content": Self::text_content("No active loop".into()) }), + ), + } + } + + pub(super) fn cmd_loop_stop(&mut self, id: Value) -> JsonRpcResponse { + match self.engine.stop() { + Ok(iter) => self.ok(id, json!({ "content": Self::text_content(format!("Loop stopped after {} iterations", iter)) })), + Err(e) => self.err(id, e.code(), format!("Failed to stop loop: {}", e)), + } + } + + pub(super) fn cmd_results(&self, id: Value, args: &Value) -> JsonRpcResponse { + let results = match self.engine.get_results() { + Ok(r) => r, + Err(e) => return self.err(id, e.code(), format!("Failed to get results: {}", e)), + }; + let iter_filter = args["iteration"].as_u64(); + + let filtered: Vec<&IterationResult> = match iter_filter { + Some(n) if n > 0 => results.iter().filter(|r| r.iteration == n as u32).collect(), + _ => results.iter().collect(), + }; + + let summary: Vec = filtered.iter().map(|r| { + json!({ + "iteration": r.iteration, "score": r.score, "status": r.status, + "actions": r.actions.iter().map(|a| json!({"agent": a.agent, "type": a.type_, "status": a.status})).collect::>(), + "feedback": r.feedback.iter().map(|f| json!({"source": f.source, "type": f.type_, "message": f.message})).collect::>(), + }) + }).collect(); + + match serde_json::to_string_pretty(&summary) { + Ok(json) => self.ok(id, json!({ "content": Self::text_content(json) })), + Err(e) => self.err(id, -32603, format!("Failed to serialize results: {}", e)), + } + } + + pub(super) fn cmd_configure(&mut self, id: Value, args: &Value) -> JsonRpcResponse { + if let Some(max) = args["maxIterations"].as_u64() { + if let Err(e) = self.engine.update_max_iterations(max as u32) { + return self.err(id, e.code(), format!("Failed to set max iterations: {}", e)); + } + } + if let Some(threshold) = args["convergenceThreshold"].as_f64() { + if let Err(e) = self.engine.update_convergence_threshold(threshold) { + return self.err(id, e.code(), format!("Failed to set threshold: {}", e)); + } + } + if let Some(dir) = args["workingDirectory"].as_str() { + let dir_path = Path::new(dir); + let resolved = match Self::validate_working_dir(dir_path) { + Ok(p) => p, + Err(e) => return self.err(id, -32602, e), + }; + if std::env::set_current_dir(&resolved).is_err() { + return self.err( + id, + -32603, + format!("Failed to set working directory: {}", dir), + ); + } + } + self.ok( + id, + json!({ "content": Self::text_content("Configuration updated".into()) }), + ) + } + + fn validate_working_dir(dir: &Path) -> Result { + let resolved = if dir.exists() { + std::fs::canonicalize(dir) + .map_err(|e| format!("Cannot resolve path {}: {}", dir.display(), e))? + } else { + let parent = dir.parent().unwrap_or(Path::new("/")); + if !parent.exists() { + return Err(format!( + "Parent directory does not exist: {}", + parent.display() + )); + } + dir.to_path_buf() + }; + if resolved.starts_with("/proc") + || resolved.starts_with("/sys") + || resolved.starts_with("/dev") + { + return Err("Cannot set working directory to a system directory".into()); + } + Ok(resolved) + } + + pub(super) fn cmd_working_dir(&self, id: Value, args: &Value) -> JsonRpcResponse { + let dir = match args["directory"].as_str() { + Some(d) => d, + None => return self.err(id, -32602, "Missing required parameter: directory".into()), + }; + let dir_path = Path::new(dir); + let resolved = match Self::validate_working_dir(dir_path) { + Ok(p) => p, + Err(e) => return self.err(id, -32602, e), + }; + if std::env::set_current_dir(&resolved).is_err() { + return self.err( + id, + -32603, + format!("Failed to set working directory: {}", dir), + ); + } + self.ok(id, json!({ "content": Self::text_content(format!("Working directory set to: {}", resolved.display())) })) + } + + pub(super) fn cmd_plugin_load(&mut self, id: Value, args: &Value) -> JsonRpcResponse { + let path = match args["path"].as_str() { + Some(p) => p, + None => return self.err(id, -32602, "Missing required parameter: path".into()), + }; + self.ok( + id, + json!({ "content": Self::text_content( + format!("Plugin loading not yet implemented in Rust version (requested path: {})", path) + ) }), + ) + } + + pub(super) fn cmd_plugin_list(&self, id: Value) -> JsonRpcResponse { + self.ok( + id, + json!({ "content": Self::text_content("No plugins loaded".into()) }), + ) + } + + pub(super) fn cmd_analyze_security(&self, id: Value, args: &Value) -> JsonRpcResponse { + let raw_files: Vec = args["files"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let mut skipped = Vec::new(); + let files: Vec = raw_files + .iter() + .filter_map(|f| match safe_path(f) { + Ok(p) => Some(p.to_string_lossy().to_string()), + Err(_) => { + skipped.push(f.clone()); + None + } + }) + .collect(); + let auditor = SecurityAuditor::new(); + let findings = auditor.audit(&files); + + if findings.is_empty() && skipped.is_empty() { + return self.ok(id, json!({ "content": Self::text_content("Security scan complete: no vulnerabilities found".into()) })); + } + let critical = findings.iter().filter(|f| f.severity == "critical").count(); + let high = findings.iter().filter(|f| f.severity == "high").count(); + let medium = findings.iter().filter(|f| f.severity == "medium").count(); + let low = findings.iter().filter(|f| f.severity == "low").count(); + + let mut report = format!("Security scan complete: {} issues found\n Critical: {}\n High: {}\n Medium: {}\n Low: {}\n", findings.len(), critical, high, medium, low); + for f in &findings { + report.push_str(&format!( + "\n[{}] {}:{} - {}\n => {}", + f.severity.to_uppercase(), + f.file, + f.line.map(|l| l.to_string()).unwrap_or_else(|| "?".into()), + f.description, + f.recommendation + )); + } + if !skipped.is_empty() { + report.push_str(&format!( + "\nSkipped {} files (not found): {}\n", + skipped.len(), + skipped.join(", ") + )); + } + self.ok(id, json!({ "content": Self::text_content(report) })) + } + + pub(super) fn cmd_analyze_code(&self, id: Value, args: &Value) -> JsonRpcResponse { + let raw_files: Vec = args["files"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let mut skipped = Vec::new(); + let files: Vec = raw_files + .iter() + .filter_map(|f| match safe_path(f) { + Ok(p) => Some(p.to_string_lossy().to_string()), + Err(_) => { + skipped.push(f.clone()); + None + } + }) + .collect(); + let refactor = RefactorEngine::new(); + let mut report = String::from("Code Analysis Results:\n"); + for file in &files { + let (issues, suggestions) = refactor.analyze(file); + if !issues.is_empty() { + report.push_str(&format!(" {}:\n", file)); + for issue in &issues { + report.push_str(&format!(" [ISSUE] {}\n", issue)); + } + for suggestion in &suggestions { + report.push_str(&format!(" [SUGGEST] {}\n", suggestion)); + } + } + } + if !skipped.is_empty() { + report.push_str(&format!( + "\nSkipped {} files (not found): {}\n", + skipped.len(), + skipped.join(", ") + )); + } + if report == "Code Analysis Results:\n" { + report = "Code analysis passed: no clean code violations detected".into(); + } + self.ok(id, json!({ "content": Self::text_content(report) })) + } + + pub(super) fn cmd_check_integrity(&self, id: Value) -> JsonRpcResponse { + let guard = AntiDeletionGuard::new(); + match guard.check_integrity() { + Ok(msg) => self.ok(id, json!({ "content": Self::text_content(msg) })), + Err(msg) => self.err(id, -32603, msg), + } + } + + pub(super) fn cmd_guard_destructive(&self, id: Value, _args: &Value) -> JsonRpcResponse { + self.ok(id, json!({ "content": Self::text_content( + "Destructive query guard active: all DROP/DELETE/TRUNCATE/ALTER operations require explicit confirmation with backup verification".into()) + })) + } + + pub(super) fn cmd_analyze_project(&self, id: Value, args: &Value) -> JsonRpcResponse { + let root = args["path"].as_str().unwrap_or("."); + let max_depth = args["depth"].as_u64().unwrap_or(4).min(10) as usize; // clamp to 10 max + let root_path = match safe_path(root) { + Ok(p) => p, + Err(e) => return self.err(id, -32602, e), + }; + + let mut report = String::new(); + report.push_str(&format!( + "[{}] ({})\n", + root_path + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_else(|| "?".into()), + root_path.display() + )); + report.push_str(&format!( + " Size: {}\n", + fs_utils::format_size(fs_utils::dir_size(&root_path, max_depth, 10000).unwrap_or(0)) + )); + + if let Some(rm) = fs_utils::find_readme(&root_path) { + if let Ok(content) = std::fs::read_to_string(&rm) { + let preview: String = content.lines().take(8).collect::>().join("\n"); + report.push_str(&format!( + " README: {} chars\n{}\n", + content.len(), + preview + )); + } + } + + let docs_dir = root_path.join("docs"); + if docs_dir.exists() && docs_dir.is_dir() { + report.push_str(&format!( + " docs/: {} files\n", + std::fs::read_dir(&docs_dir).map(|e| e.count()).unwrap_or(0) + )); + } + + let configs = fs_utils::scan_configs(&root_path); + if !configs.is_empty() { + report.push_str(&format!(" Config: {}\n", configs.join(", "))); + } + + let mut entries = fs_utils::collect_entries(&root_path, 0, max_depth, 500); + entries.sort_by_key(|a| a.1); + for (path_str, depth, is_dir, size) in &entries { + if *depth == 0 || *depth > max_depth { + continue; + } + let indent = " ".repeat(*depth); + let marker = if *is_dir { "+" } else { " " }; + report.push_str(&format!( + "{}{} {}{}\n", + indent, + marker, + path_str, + if *is_dir { + String::new() + } else { + format!(" ({})", fs_utils::format_size(*size)) + } + )); + } + + let large: Vec<_> = entries + .iter() + .filter(|(_, _, is_dir, size)| !is_dir && *size > 100_000) + .collect(); + if !large.is_empty() { + report.push_str(&format!("\n Large files (>100KB): {}\n", large.len())); + } + + self.ok(id, json!({ "content": Self::text_content(report) })) + } + + fn is_private_ip(host: &str) -> bool { + if host == "localhost" || host == "127.0.0.1" || host == "::1" { + return true; + } + if host.ends_with(".local") || host.ends_with(".internal") { + return true; + } + if let Ok(addr) = host.parse::() { + match addr { + std::net::IpAddr::V4(a) => { + return a.is_loopback() || a.is_private() || a.is_link_local() + } + std::net::IpAddr::V6(a) => return a.is_loopback() || a.is_unicast_link_local(), + } + } + false + } + + pub(super) fn cmd_mcp_call(&self, id: Value, args: &Value) -> JsonRpcResponse { + let server_url = match args["server_url"].as_str() { + Some(u) => u, + None => return self.err(id, -32602, "Missing required parameter: server_url".into()), + }; + let parsed = match url::Url::parse(server_url) { + Ok(u) => u, + Err(e) => return self.err(id, -32602, format!("Invalid server_url: {}", e)), + }; + let host = parsed.host_str().unwrap_or(""); + if host.is_empty() || Self::is_private_ip(host) { + return self.err( + id, + -32602, + "SSRF protection: requests to private/local addresses are blocked".into(), + ); + } + let tool_name = match args["tool_name"].as_str() { + Some(t) => t, + None => return self.err(id, -32602, "Missing required parameter: tool_name".into()), + }; + let arguments = args.get("arguments").cloned().unwrap_or(json!({})); + let endpoint = args["endpoint"].as_str().unwrap_or("/message"); + let url = format!("{}{}", server_url.trim_end_matches('/'), endpoint); + let request_body = json!({ + "jsonrpc": "2.0", "id": "1", "method": "tools/call", + "params": { "name": tool_name, "arguments": arguments } + }); + + let result = Self::http_post(&url, &request_body); + match result { + Ok(body) => self.ok(id, json!({ "content": Self::text_content(body) })), + Err(e) => self.err(id, -32603, format!("MCP call failed: {}", e)), + } + } + + fn http_post(url: &str, body: &Value) -> Result { + let url_owned = url.to_string(); + let body_str = + serde_json::to_string(body).map_err(|e| format!("Serialization error: {}", e))?; + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let result = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("HTTP client build error: {}", e)) + .and_then(|client| { + client + .post(&url_owned) + .header("Content-Type", "application/json") + .body(body_str.clone()) + .send() + .map_err(|e| format!("HTTP request failed: {}", e)) + .and_then(|resp| resp.text().map_err(|e| format!("HTTP read error: {}", e))) + }); + let _ = tx.send(result); + }); + rx.recv_timeout(std::time::Duration::from_secs(35)) + .map_err(|_| "MCP call timed out".to_string())? + } + + pub(super) fn cmd_version(&self, id: Value) -> JsonRpcResponse { + let version = env!("CARGO_PKG_VERSION"); + let build_ts = option_env!("BUILD_TIME").unwrap_or("0"); + let git_head = option_env!("GIT_HEAD").unwrap_or("unknown"); + let msg = format!( + "Oura v{} | build: {} | commit: {}", + version, build_ts, git_head + ); + self.ok(id, json!({ "content": Self::text_content(msg) })) + } + + pub(super) async fn cmd_update(&self, id: Value, args: &Value) -> JsonRpcResponse { + let apply = args["apply"].as_bool().unwrap_or(false); + let project_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + + if !project_dir.join(".git").exists() { + return self.ok(id, json!({ "content": Self::text_content("Not a git repository. Can't auto-update.".into()) })); + } + + let default_branch = tokio::process::Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(project_dir) + .output() + .await + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty() && s != "HEAD") + .unwrap_or_else(|| "main".into()); + + let mut report = format!( + "Oura v{} - checking for updates...\n", + env!("CARGO_PKG_VERSION") + ); + + match run_command_timeout(&["git", "fetch", "--quiet"], project_dir, 30).await { + Ok(output) if output.status.success() => report.push_str("Git fetch OK.\n"), + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + report.push_str(&format!("Git fetch: {}\n", stderr)); + return self.ok(id, json!({ "content": Self::text_content(report) })); + } + Err(e) => { + report.push_str(&format!("Git fetch failed: {}\n", e)); + return self.ok(id, json!({ "content": Self::text_content(report) })); + } + } + + let remote_ref = format!("HEAD..origin/{}", default_branch); + let ahead = tokio::process::Command::new("git") + .args(["rev-list", "--count", &remote_ref]) + .current_dir(project_dir) + .output() + .await; + let commits_behind = match ahead { + Ok(output) => String::from_utf8_lossy(&output.stdout).trim().to_string(), + Err(_) => "?".to_string(), + }; + let behind_count: i32 = commits_behind.parse().unwrap_or(-1); + + if behind_count > 0 { + report.push_str(&format!( + "{} commits behind origin/{}\n", + behind_count, default_branch + )); + if apply { + report.push_str("Applying update...\n"); + match run_command_timeout(&["git", "pull", "--ff-only"], project_dir, 30).await { + Ok(output) if output.status.success() => report.push_str("Git pull OK.\n"), + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + report.push_str(&format!("Git pull failed: {}\n", stderr)); + return self.ok(id, json!({ "content": Self::text_content(report) })); + } + Err(e) => { + report.push_str(&format!("Git pull failed: {}\n", e)); + return self.ok(id, json!({ "content": Self::text_content(report) })); + } + } + report.push_str("Running cargo build --release...\n"); + match run_command_timeout(&["cargo", "build", "--release"], project_dir, 300).await + { + Ok(output) if output.status.success() => report.push_str("Build OK.\n"), + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + report.push_str(&format!("Build failed: {}\n", stderr)); + } + Err(e) => report.push_str(&format!("Build failed: {}\n", e)), + } + } else { + report.push_str("Use oura_update with apply=true to upgrade.\n"); + } + } else if behind_count == 0 { + report.push_str(&format!( + "Already up to date with origin/{}\n", + default_branch + )); + } else { + report.push_str("Could not determine update status.\n"); + } + + self.ok(id, json!({ "content": Self::text_content(report) })) + } + + pub(super) fn cmd_profile(&self, id: Value, args: &Value) -> JsonRpcResponse { + let path = args["path"] + .as_str() + .map(Path::new) + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + let profile = crate::profile::ProjectProfile::detect(&path); + self.ok( + id, + json!({ "content": Self::text_content(profile.summary()) }), + ) + } + + pub(super) fn cmd_verify(&self, id: Value, args: &Value) -> JsonRpcResponse { + let path = args["path"] + .as_str() + .map(Path::new) + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + let check_licenses = args["check_licenses"].as_bool().unwrap_or(true); + let check_versions = args["check_versions"].as_bool().unwrap_or(true); + + let profile = crate::profile::ProjectProfile::detect(&path); + let verify = crate::profile::verify_dependencies(&path); + + let mut report = profile.summary(); + if check_licenses && !verify.license_issues.is_empty() { + report.push_str(&format!( + "\nLicense issues:\n {}", + verify.license_issues.join("\n ") + )); + } + if check_versions && !verify.version_issues.is_empty() { + report.push_str(&format!( + "\nVersion issues:\n {}", + verify.version_issues.join("\n ") + )); + } + if report.is_empty() { + report = "No issues found".into(); + } + + self.ok(id, json!({ "content": Self::text_content(report) })) + } + + pub(super) async fn cmd_connector(&self, id: Value, args: &Value) -> JsonRpcResponse { + let tool = match args["tool"].as_str() { + Some(t) => t, + None => return self.err(id, -32602, "Missing required parameter: tool".into()), + }; + let arguments = args.get("arguments").cloned().unwrap_or(json!({})); + let transport = args["transport"].as_str().unwrap_or("http"); + + match transport { + "quic" => { + let host = args["host"].as_str().unwrap_or("127.0.0.1"); + let port = args["port"].as_u64().unwrap_or(7439) as u16; + + match crate::connector::QuicConnector::new() { + Ok(conn) => match conn.call_tool(host, port, tool, &arguments).await { + Ok(body) => self.ok(id, json!({ "content": Self::text_content(body) })), + Err(e) => self.err(id, -32603, format!("QUIC call failed: {}", e)), + }, + Err(e) => self.err(id, -32603, format!("QUIC init failed: {}", e)), + } + } + _ => { + let server_url = args["server_url"] + .as_str() + .unwrap_or("http://localhost:7438"); + let endpoint = args["endpoint"].as_str().unwrap_or("/message"); + + match crate::feedback::call_mcp_tool(server_url, endpoint, tool, &arguments).await { + Ok(body) => self.ok(id, json!({ "content": Self::text_content(body) })), + Err(e) => self.err(id, -32603, format!("HTTP call failed: {}", e)), + } + } + } + } + + pub(super) fn cmd_cleanup(&self, id: Value, args: &Value) -> JsonRpcResponse { + let root = args["path"].as_str().unwrap_or("."); + let dry_run = args["dry_run"].as_bool().unwrap_or(true); + let confirm = args["confirm"].as_bool().unwrap_or(false); + let older_than = args["older_than_days"].as_u64().unwrap_or(30); + let max_depth = args["max_depth"].as_u64().unwrap_or(20) as usize; + let default_patterns: Vec = vec![ + "*.tmp".into(), + "*.temp".into(), + "*.log".into(), + "*.bak".into(), + "*.swp".into(), + "*.swo".into(), + "*.pyc".into(), + "__pycache__".into(), + ".DS_Store".into(), + "Thumbs.db".into(), + ]; + let default_dir_patterns: Vec = vec![ + "node_modules".into(), + ".next".into(), + ".turbo".into(), + "dist".into(), + "build".into(), + ".cache".into(), + ]; + let patterns: Vec = args["patterns"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_else(|| default_patterns.clone()); + let dir_patterns: Vec = args["dir_patterns"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_else(|| default_dir_patterns); + + let root_path = match safe_path(root) { + Ok(p) => p, + Err(e) => return self.err(id, -32602, e), + }; + + if !dry_run && !confirm { + return self.err( + id, + -32602, + "Deletion requires confirm=true. Run with dry_run=true first to preview.".into(), + ); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let max_age = if older_than > 0 { + older_than.saturating_mul(86400) + } else { + u64::MAX + }; + + let mut ctx = CleanupContext::new(patterns, dir_patterns.to_vec(), now, max_age, max_depth); + ctx.walk(&root_path, 0); + + let candidates = ctx.candidates().to_vec(); + let total_size = ctx.total_size(); + + if candidates.is_empty() { + return self.ok( + id, + json!({ "content": Self::text_content("No files to clean up.".into()) }), + ); + } + + let mut report = format!( + "Found {} candidates ({}):\n", + candidates.len(), + fs_utils::format_size(total_size) + ); + for (path, kind) in &candidates { + report.push_str(&format!(" [{}] {}\n", kind, path)); + } + + if !dry_run { + let mut deleted = 0u64; + let mut freed = 0u64; + for (path_str, kind) in &candidates { + let p = Path::new(path_str); + let size = p.metadata().ok().map(|m| m.len()).unwrap_or(0); + if kind == "dir" { + std::fs::remove_dir_all(p).ok(); + } else { + std::fs::remove_file(p).ok(); + } + deleted += 1; + freed += size; + } + report.push_str(&format!( + "\nCleaned: {} items, freed {}\n", + deleted, + fs_utils::format_size(freed) + )); + } else { + report.push_str( + "\nDry-run mode. Set dry_run=false and confirm=true to actually delete.\n", + ); + } + + self.ok(id, json!({ "content": Self::text_content(report) })) + } +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs new file mode 100644 index 0000000..fc563a3 --- /dev/null +++ b/src/mcp/mod.rs @@ -0,0 +1,295 @@ +pub mod handlers; +pub mod resources; + +use crate::engine::LoopEngine; +use crate::types::*; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +pub struct McpServer { + pub(super) engine: LoopEngine, +} + +impl McpServer { + pub fn new(engine: LoopEngine) -> Self { + Self { engine } + } + + pub async fn run(&mut self) -> anyhow::Result<()> { + let stdin = tokio::io::stdin(); + let mut stdout = tokio::io::stdout(); + let mut reader = BufReader::new(stdin); + + loop { + let mut line = String::new(); + match tokio::time::timeout( + std::time::Duration::from_secs(300), + reader.read_line(&mut line), + ) + .await + { + Ok(Ok(0)) => break, + Ok(Ok(_)) => { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let request: JsonRpcRequest = match serde_json::from_str(trimmed) { + Ok(req) => req, + Err(e) => { + tracing::error!(error = %e, message = %trimmed, "Parse error"); + let err_resp = json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": format!("Parse error: {}", e)}}); + if let Ok(output) = serde_json::to_string(&err_resp) { + let _ = stdout.write_all(output.as_bytes()).await; + let _ = stdout.write_all(b"\n").await; + let _ = stdout.flush().await; + } + continue; + } + }; + + let response = self.handle_request(&request).await; + if response.id.is_null() { + continue; + } + + if let Ok(output) = serde_json::to_string(&response) { + let _ = stdout.write_all(output.as_bytes()).await; + let _ = stdout.write_all(b"\n").await; + let _ = stdout.flush().await; + } + } + Ok(Err(e)) => { + tracing::error!(error = %e, "Failed to read line"); + break; + } + Err(_) => { + tracing::trace!("stdin idle timeout"); + continue; + } + } + } + Ok(()) + } + + async fn handle_request(&mut self, request: &JsonRpcRequest) -> JsonRpcResponse { + let id = request.id.clone(); + match request.method.as_str() { + "initialize" => self.handle_initialize(id, request.params.as_ref()), + "initialized" => JsonRpcResponse { + jsonrpc: "2.0".into(), + id, + result: None, + error: None, + }, + "tools/list" => self.handle_tools_list(id), + "tools/call" => self.handle_tools_call(id, request.params.as_ref()).await, + "resources/list" => self.handle_resources_list(id), + "resources/read" => self.handle_resource_read(id, request.params.as_ref()), + "prompts/list" => self.handle_prompts_list(id), + "prompts/get" => self.handle_prompt_get(id, request.params.as_ref()), + "ping" => self.ok(id, json!({})), + _ => JsonRpcResponse { + jsonrpc: "2.0".into(), + id, + result: None, + error: Some(JsonRpcError { + code: -32601, + message: format!("Method not found: {}", request.method), + data: None, + }), + }, + } + } + + fn ok(&self, id: Value, result: Value) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0".into(), + id, + result: Some(result), + error: None, + } + } + + fn err(&self, id: Value, code: i32, message: String) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0".into(), + id, + result: None, + error: Some(JsonRpcError { + code, + message, + data: None, + }), + } + } + + fn text_content(text: String) -> Value { + json!([{ "type": "text", "text": text }]) + } + + const SUPPORTED_PROTOCOLS: &'static [&'static str] = &["2024-11-05", "2025-03-26"]; + + fn handle_initialize(&self, id: Value, params: Option<&Value>) -> JsonRpcResponse { + let client_version = params + .and_then(|p| p["protocolVersion"].as_str()) + .unwrap_or("unknown"); + if !Self::SUPPORTED_PROTOCOLS.contains(&client_version) { + tracing::warn!( + client = client_version, + "MCP client using unsupported protocol version" + ); + } + self.ok(id, json!({ + "protocolVersion": "2024-11-05", + "capabilities": { "tools": { "listChanged": true }, "resources": { "listChanged": true }, "prompts": { "listChanged": true } }, + "serverInfo": { "name": "oura", "version": env!("CARGO_PKG_VERSION") } + })) + } + + fn handle_tools_list(&self, id: Value) -> JsonRpcResponse { + let tools = vec![ + McpToolDefinition { + name: "oura_start_loop".into(), + description: "Start a new iteration loop to achieve a specific goal. Use manual=true to drive iterations yourself via oura_iterate.".into(), + input_schema: json!({"type": "object", "properties": {"goal": { "type": "string", "description": "The goal to achieve" }, "maxIterations": { "type": "integer", "description": "Maximum iterations (default: 20)", "default": 20 }, "manual": { "type": "boolean", "description": "Manual mode: you call oura_iterate for each step (default: false)", "default": false } }, "required": ["goal"]}), + }, + McpToolDefinition { + name: "oura_iterate".into(), + description: "Execute a single iteration step manually (use after oura_start_loop with manual=true)".into(), + input_schema: json!({"type": "object", "properties": {}}), + }, + McpToolDefinition { + name: "oura_loop_status".into(), + description: "Get current status of the active loop".into(), + input_schema: json!({"type": "object", "properties": {}}), + }, + McpToolDefinition { + name: "oura_loop_stop".into(), + description: "Stop the currently running loop".into(), + input_schema: json!({"type": "object", "properties": {}}), + }, + McpToolDefinition { + name: "oura_results".into(), + description: "Get accumulated results from all iterations".into(), + input_schema: json!({"type": "object", "properties": {"iteration": { "type": "number", "description": "Specific iteration to view (omit or 0 for all)" }}}), + }, + McpToolDefinition { + name: "oura_configure".into(), + description: "Update Oura engine configuration".into(), + input_schema: json!({"type": "object", "properties": {"maxIterations": { "type": "number", "description": "Maximum iterations" }, "convergenceThreshold": { "type": "number", "description": "Score threshold (0-100)" }, "workingDirectory": { "type": "string", "description": "Working directory" }}}), + }, + McpToolDefinition { + name: "oura_plugin_load".into(), + description: "[EXPERIMENTAL — not yet implemented] Load a plugin from a directory path".into(), + input_schema: json!({"type": "object", "properties": {"path": { "type": "string", "description": "Path to plugin directory" }}, "required": ["path"]}), + }, + McpToolDefinition { + name: "oura_plugin_list".into(), + description: "[EXPERIMENTAL — not yet implemented] List all loaded plugins".into(), + input_schema: json!({"type": "object", "properties": {}}), + }, + McpToolDefinition { + name: "oura_analyze_security".into(), + description: "Run security audit on specified files (pass at least one file, or you get an empty report)".into(), + input_schema: json!({"type": "object", "properties": {"files": { "type": "array", "items": { "type": "string" }, "description": "Files to scan (pass at least one)" }}}), + }, + McpToolDefinition { + name: "oura_analyze_code".into(), + description: "Analyze code for clean code violations (pass at least one file)".into(), + input_schema: json!({"type": "object", "properties": {"files": { "type": "array", "items": { "type": "string" }, "description": "Files to analyze (pass at least one)" }}}), + }, + McpToolDefinition { + name: "oura_check_integrity".into(), + description: "Check code integrity — always passes (baseline comparison not yet implemented)".into(), + input_schema: json!({"type": "object", "properties": {}}), + }, + McpToolDefinition { + name: "oura_guard_destructive".into(), + description: "[PLACEHOLDER] Acknowledge destructive operation guard (actual scanning not yet implemented)".into(), + input_schema: json!({"type": "object", "properties": {}}), + }, + McpToolDefinition { + name: "oura_analyze_project".into(), + description: "Scan project directory structure, docs, configs and sizes. Token-efficient project analysis for organization decisions.".into(), + input_schema: json!({"type": "object", "properties": {"path": { "type": "string", "description": "Project root path (default: current dir)" }, "depth": { "type": "number", "description": "Max directory depth (default: 4, max: 10)", "default": 4 }}}), + }, + McpToolDefinition { + name: "oura_cleanup".into(), + description: "Scan and clean temp files, build artifacts, caches and old logs. Safe dry-run by default.".into(), + input_schema: json!({"type": "object", "properties": {"path": { "type": "string", "description": "Root path to scan (default: current dir)" }, "dry_run": { "type": "boolean", "description": "Only report, don't delete (default: true)", "default": true }, "confirm": { "type": "boolean", "description": "Must be true when dry_run=false to actually delete", "default": false }, "max_depth": { "type": "number", "description": "Max directory depth (default: 20)", "default": 20 }, "older_than_days": { "type": "number", "description": "Only touch files older than N days (default: 30)", "default": 30 }, "patterns": { "type": "array", "items": { "type": "string" }, "description": "File patterns to match (default: *.tmp, *.log, *.bak, __pycache__, etc)", "default": ["*.tmp", "*.temp", "*.log", "*.bak", "*.swp", "*.swo", "*.pyc", "__pycache__", ".DS_Store", "Thumbs.db"] }, "dir_patterns": { "type": "array", "items": { "type": "string" }, "description": "Directory names to remove entirely (default: node_modules, .next, .turbo, dist, build, .cache)", "default": ["node_modules", ".next", ".turbo", "dist", "build", ".cache"] }}}), + }, + McpToolDefinition { + name: "mcp_call".into(), + description: "Call a tool on another MCP server via HTTP. Note: private/local addresses (localhost, 127.0.0.1, private IPs) are blocked for security.".into(), + input_schema: json!({"type": "object", "properties": {"server_url": { "type": "string", "description": "Base URL of target MCP server (e.g. http://example.com:7438)" }, "tool_name": { "type": "string", "description": "Name of the tool to call" }, "arguments": { "type": "object", "description": "Arguments for the tool", "default": {} }, "endpoint": { "type": "string", "description": "Custom endpoint path (default: /message)", "default": "/message" } }, "required": ["server_url", "tool_name"]}), + }, + McpToolDefinition { + name: "oura_version".into(), + description: "Show Oura version and build info.".into(), + input_schema: json!({"type": "object", "properties": {}}), + }, + McpToolDefinition { + name: "oura_update".into(), + description: "Check for updates and optionally rebuild Oura from source via git pull + cargo build.".into(), + input_schema: json!({"type": "object", "properties": {"apply": { "type": "boolean", "description": "Actually pull and rebuild (default: false = dry-run check)", "default": false }}}), + }, + McpToolDefinition { + name: "oura_profile".into(), + description: "Detect project profile: indie, studio, enterprise, game-dev, ai-ml, etc. Lightweight analysis, no telemetry.".into(), + input_schema: json!({"type": "object", "properties": {"path": { "type": "string", "description": "Project path (default: current dir)" }}}), + }, + McpToolDefinition { + name: "oura_verify".into(), + description: "Verify dependency licenses, versions, and project profile. Checks Cargo.toml, package.json, Cargo.lock.".into(), + input_schema: json!({"type": "object", "properties": {"path": { "type": "string", "description": "Project path (default: current dir)" }, "check_licenses": { "type": "boolean", "description": "Check dependency licenses (default: true)", "default": true }, "check_versions": { "type": "boolean", "description": "Check version stability (default: true)", "default": true }}}), + }, + McpToolDefinition { + name: "oura_working_dir".into(), + description: "Set the process working directory (affects all subsequent Oura commands and file operations)".into(), + input_schema: json!({"type": "object", "properties": {"directory": { "type": "string", "description": "Absolute path to the working directory" }}, "required": ["directory"]}), + }, + McpToolDefinition { + name: "oura_connector".into(), + description: "Call a tool on an external MCP server (e.g. Synapsis) via HTTP or QUIC. Used for cross-server synergy — call this manually or configure [connector] in config for auto-call during iteration.".into(), + input_schema: json!({"type": "object", "properties": {"tool": { "type": "string", "description": "Name of the tool to call on the external server" }, "transport": { "type": "string", "description": "Transport protocol: 'http' (default) or 'quic'", "default": "http" }, "server_url": { "type": "string", "description": "HTTP URL (only for transport=http)", "default": "http://localhost:7438" }, "host": { "type": "string", "description": "QUIC host (only for transport=quic)", "default": "127.0.0.1" }, "port": { "type": "number", "description": "QUIC port (only for transport=quic)", "default": 7439 }, "arguments": { "type": "object", "description": "Arguments for the tool" }, "endpoint": { "type": "string", "description": "HTTP endpoint path (default: /message)", "default": "/message" }}, "required": ["tool"]}), + }, + ]; + self.ok(id, json!({ "tools": tools })) + } + + async fn handle_tools_call(&mut self, id: Value, params: Option<&Value>) -> JsonRpcResponse { + let params = match params { + Some(p) => p, + None => return self.err(id, -32602, "Missing params".into()), + }; + let name = params["name"].as_str().unwrap_or(""); + let args = params.get("arguments").cloned().unwrap_or(json!({})); + + match name { + "oura_start_loop" => self.cmd_start_loop(id, &args).await, + "oura_iterate" => self.cmd_iterate(id).await, + "oura_loop_status" => self.cmd_loop_status(id), + "oura_loop_stop" => self.cmd_loop_stop(id), + "oura_results" => self.cmd_results(id, &args), + "oura_configure" => self.cmd_configure(id, &args), + "oura_plugin_load" => self.cmd_plugin_load(id, &args), + "oura_plugin_list" => self.cmd_plugin_list(id), + "oura_analyze_security" => self.cmd_analyze_security(id, &args), + "oura_analyze_code" => self.cmd_analyze_code(id, &args), + "oura_check_integrity" => self.cmd_check_integrity(id), + "oura_guard_destructive" => self.cmd_guard_destructive(id, &args), + "oura_analyze_project" => self.cmd_analyze_project(id, &args), + "oura_cleanup" => self.cmd_cleanup(id, &args), + "mcp_call" => self.cmd_mcp_call(id, &args), + "oura_version" => self.cmd_version(id), + "oura_update" => self.cmd_update(id, &args).await, + "oura_profile" => self.cmd_profile(id, &args), + "oura_verify" => self.cmd_verify(id, &args), + "oura_connector" => self.cmd_connector(id, &args).await, + "oura_working_dir" => self.cmd_working_dir(id, &args), + _ => self.err(id, -32602, format!("Unknown tool: {}", name)), + } + } +} diff --git a/src/mcp/resources.rs b/src/mcp/resources.rs new file mode 100644 index 0000000..45d1d06 --- /dev/null +++ b/src/mcp/resources.rs @@ -0,0 +1,89 @@ +use crate::mcp::McpServer; +use crate::types::JsonRpcResponse; +use serde_json::{json, Value}; + +impl McpServer { + pub(super) fn handle_resources_list(&self, id: Value) -> JsonRpcResponse { + self.ok(id, json!({ + "resources": [ + { "uri": "oura://state", "name": "Loop State", "mimeType": "application/json" }, + { "uri": "oura://results", "name": "Loop Results", "mimeType": "application/json" }, + { "uri": "oura://config", "name": "Oura Config", "mimeType": "application/json" }, + ] + })) + } + + pub(super) fn handle_resource_read( + &self, + id: Value, + params: Option<&Value>, + ) -> JsonRpcResponse { + let uri = params.and_then(|p| p["uri"].as_str()).unwrap_or(""); + + let content = match uri { + "oura://state" => match self.engine.get_state() { + Ok(Some(state)) => serde_json::to_string_pretty(&state) + .unwrap_or_else(|e| format!("Serialization error: {}", e)), + Ok(None) => "No active loop".into(), + Err(e) => return self.err(id, e.code(), format!("Failed to get state: {}", e)), + }, + "oura://results" => { + let results = match self.engine.get_results() { + Ok(r) => r, + Err(e) => { + return self.err(id, e.code(), format!("Failed to get results: {}", e)) + } + }; + serde_json::to_string_pretty(&results) + .unwrap_or_else(|e| format!("Serialization error: {}", e)) + } + "oura://config" => { + let max_iter = self.engine.max_iterations().unwrap_or(20); + let threshold = self.engine.convergence_threshold().unwrap_or(90.0); + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + let config_json = json!({ + "max_iterations": max_iter, "convergence_threshold": threshold, "working_directory": cwd, + }); + serde_json::to_string_pretty(&config_json) + .unwrap_or_else(|e| format!("Serialization error: {}", e)) + } + _ => return self.err(id, -32602, format!("Unknown resource: {}", uri)), + }; + + self.ok(id, json!({ "contents": [{ "uri": uri, "mimeType": "application/json", "text": content }] })) + } + + pub(super) fn handle_prompts_list(&self, id: Value) -> JsonRpcResponse { + self.ok(id, json!({ + "prompts": [ + { "name": "start_loop", "description": "Template for starting a new Oura iteration loop", "arguments": [{ "name": "goal", "description": "What to achieve", "required": true }] }, + { "name": "loop_summary", "description": "Template for summarizing loop results", "arguments": [{ "name": "iteration", "description": "Which iteration to summarize", "required": false }] }, + ] + })) + } + + pub(super) fn handle_prompt_get(&self, id: Value, params: Option<&Value>) -> JsonRpcResponse { + let name = params.and_then(|p| p["name"].as_str()).unwrap_or(""); + match name { + "start_loop" => { + let goal = params.and_then(|p| p["arguments"].as_object()) + .and_then(|a| a.get("goal")).and_then(|v| v.as_str()).unwrap_or("improve codebase"); + self.ok(id, json!({ + "messages": [{ + "role": "user", "content": { "type": "text", "text": format!( + "I want to start an Oura iteration loop to: {}\n\nPlease call oura_start_loop with goal=\"{}\".\nOura will run iterations until convergence or max iterations reached.\nYou can check progress with oura_loop_status and finally oura_results.", goal, goal + )} + }] + })) + } + "loop_summary" => { + self.ok(id, json!({ + "messages": [{ "role": "user", "content": { "type": "text", "text": "Please summarize the Oura loop results. Use oura_results to get the data first." } }] + })) + } + _ => self.err(id, -32602, format!("Unknown prompt: {}", name)), + } + } +} diff --git a/src/profile.rs b/src/profile.rs index 5fdec62..49abae1 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -126,7 +126,6 @@ impl ProjectProfile { "photosh", "adobe", "maya", - "blender", " Substance ", "houdini", "nuke", @@ -296,7 +295,14 @@ fn classify(enterprise: bool, paid_tools: bool, indicators: &[String]) -> Profil ), ( &[ - "unreal", "unity", "godot", "bevy", "amethyst", "three.js", "babylon", + "unreal", + "unity", + "godot", + "bevy", + "amethyst", + "three.js", + "babylon", + "game-engine", ], "game-dev", ), @@ -468,8 +474,19 @@ fn classify(enterprise: bool, paid_tools: bool, indicators: &[String]) -> Profil *scores.entry("enterprise").or_insert(0) += 1; } if paid_tools { + if indicators + .iter() + .any(|i| i.contains("datadog") || i.contains("newrelic") || i.contains("sentry")) + { + *scores.entry("enterprise").or_insert(0) += 1; + } + if indicators + .iter() + .any(|i| i.contains("maya") || i.contains("3ds")) + { + *scores.entry("3d-artist").or_insert(0) += 1; + } *scores.entry("studio").or_insert(0) += 1; - *scores.entry("3d-artist").or_insert(0) += 1; } scores @@ -487,18 +504,40 @@ fn classify(enterprise: bool, paid_tools: bool, indicators: &[String]) -> Profil fn count_cargo_deps(root: &Path) -> usize { if let Ok(content) = std::fs::read_to_string(root.join("Cargo.toml")) { + if let Ok(value) = content.parse::() { + let mut count = 0; + if let Some(table) = value.as_table() { + for &key in &["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(deps) = table.get(key).and_then(|v| v.as_table()) { + count += deps.len(); + } + } + if let Some(target) = table.get("target").and_then(|v| v.as_table()) { + for (_, cfg) in target { + if let Some(t) = cfg.as_table() { + for &key in &["dependencies", "dev-dependencies", "build-dependencies"] + { + if let Some(deps) = t.get(key).and_then(|v| v.as_table()) { + count += deps.len(); + } + } + } + } + } + } + return count; + } let mut count = 0; - let mut in_deps = false; for line in content.lines() { let trimmed = line.trim(); if trimmed.starts_with('[') { - in_deps = trimmed.starts_with("[dependencies]") - || trimmed.starts_with("[dev-dependencies]") - || trimmed.starts_with("[build-dependencies]"); continue; } - if in_deps && trimmed.contains('=') && !trimmed.starts_with('#') { - count += 1; + if trimmed.contains('=') && !trimmed.starts_with('#') { + let eq_count = trimmed.chars().filter(|&c| c == '=').count(); + if eq_count == 1 { + count += 1; + } } } return count; @@ -558,7 +597,7 @@ struct LicenseCheckResult { fn check_cargo_licenses(root: &Path) -> LicenseCheckResult { let mut deps = Vec::new(); let mut license_issues = Vec::new(); - let version_issues = Vec::new(); + let mut version_issues = Vec::new(); let restricted = ["BUSL-1.1", "BSL-1.1", "AGPL-3.0", "SSPL-1.0", "Elastic-2.0"]; @@ -571,6 +610,12 @@ fn check_cargo_licenses(root: &Path) -> LicenseCheckResult { if let Some(name) = current_pkg.get("name") { let version: String = current_pkg.get("version").cloned().unwrap_or_default(); let license = current_pkg.get("license").cloned(); + if version.starts_with("0.") { + version_issues.push(format!( + "{}: pre-release version ({}) - may be unstable", + name, version + )); + } let info = DependencyInfo { name: name.clone(), version, @@ -607,6 +652,12 @@ fn check_cargo_licenses(root: &Path) -> LicenseCheckResult { if let Some(name) = current_pkg.get("name") { let version = current_pkg.get("version").cloned().unwrap_or_default(); let license = current_pkg.get("license").cloned(); + if version.starts_with("0.") { + version_issues.push(format!( + "{}: pre-release version ({}) - may be unstable", + name, version + )); + } deps.push(DependencyInfo { name: name.clone(), version, @@ -690,6 +741,7 @@ fn check_node_dep_license(root: &Path, name: &str) -> Option { } impl VerifyReport { + #[allow(dead_code)] pub fn summary(&self) -> String { let mut out = String::new(); out.push_str(&format!("Profile:\n{}\n\n", self.profile.summary())); @@ -722,12 +774,8 @@ impl VerifyReport { } fn read_first_n_chars(path: std::path::PathBuf, n: usize) -> std::io::Result { - use std::io::Read; - let mut file = std::fs::File::open(path)?; - let mut buf = vec![0u8; n]; - let bytes_read = file.read(&mut buf)?; - buf.truncate(bytes_read); - Ok(String::from_utf8_lossy(&buf).to_string()) + let content = std::fs::read_to_string(path)?; + Ok(content.chars().take(n).collect()) } #[cfg(test)] @@ -790,7 +838,7 @@ mod tests { fn test_classify_game_dev() { let indicators = vec!["game-engine: rust".to_string()]; let result = classify(false, false, &indicators); - assert_eq!(result, "indie"); + assert_eq!(result, "game-dev"); } #[test] @@ -803,6 +851,16 @@ mod tests { assert_eq!(count, 3); } + #[test] + fn test_count_cargo_deps_inline_table() { + let temp = TempDir::new().unwrap(); + let cargo_toml = temp.path().join("Cargo.toml"); + fs::write(&cargo_toml, "[dependencies]\nserde = { version = \"1.0\", features = [\"derive\"] }\ntokio = \"1.0\"").unwrap(); + + let count = count_cargo_deps(temp.path()); + assert_eq!(count, 2); + } + #[test] fn test_count_json_deps() { let temp = TempDir::new().unwrap(); diff --git a/src/terminal_writer.rs b/src/terminal_writer.rs new file mode 100644 index 0000000..b1f6662 --- /dev/null +++ b/src/terminal_writer.rs @@ -0,0 +1,150 @@ +//! Terminal Writer - Direct User Input Zone Communication +//! +//! Allows agents to write coordination messages directly to +//! user's terminal input zones when CLI/IDEs are waiting. + +use anyhow::Result; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; + +/// Terminal Writer for agent coordination +#[allow(dead_code)] +pub struct TerminalWriter { + message_file: PathBuf, +} + +#[allow(dead_code)] +impl TerminalWriter { + pub fn new() -> Self { + Self { + message_file: std::env::temp_dir().join("synapsis-agent-messages.txt"), + } + } + + /// Write coordination message to terminal input zone + pub fn write_to_terminal(&self, message: &str, agent_id: &str) -> Result<()> { + let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"); + + let formatted = format!( + "\n╔══════════════════════════════════════════════════════════╗\n\ + ║ 📡 SYNAPSIS AGENT COORDINATION MESSAGE ║\n\ + ╠══════════════════════════════════════════════════════════╣\n\ + ║ From: {:<52} ║\n\ + ║ Time: {:<52} ║\n\ + ╠══════════════════════════════════════════════════════════╣\n\ + ║ Message:\n\ + ║ {:<52} ║\n\ + ╚══════════════════════════════════════════════════════════╝\n\n\ + ⚠️ This is an automated coordination message.\n\ + 💡 The agent is waiting for your confirmation.\n\n", + agent_id, + timestamp, + Self::wrap_text(message, 52) + ); + + // Append to message file + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&self.message_file)?; + + file.write_all(formatted.as_bytes())?; + file.flush()?; + + // Also print to stdout for immediate visibility + print!("{}", formatted); + std::io::stdout().flush()?; + + Ok(()) + } + + /// Write coordination prompt (for user action) + pub fn write_prompt(&self, _prompt: &str, agent_id: &str, action_required: &str) -> Result<()> { + let message = format!( + "🔔 COORDINATION REQUIRED\n\n\ + Agent '{}' is waiting for:\n\ + {}\n\n\ + Please confirm when completed.", + agent_id, action_required + ); + + self.write_to_terminal(&message, agent_id)?; + + // Also write to prompt-specific file + let prompt_file = std::env::temp_dir().join("synapsis-agent-prompt.txt"); + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&prompt_file)?; + + file.write_all(format!("{}\n{}\n", agent_id, action_required).as_bytes())?; + file.flush()?; + + Ok(()) + } + + /// Clear all agent messages + pub fn clear_messages(&self) -> Result<()> { + if self.message_file.exists() { + std::fs::write(&self.message_file, "")?; + } + Ok(()) + } + + /// Get recent messages + pub fn get_recent_messages(&self, lines: usize) -> Result { + if !self.message_file.exists() { + return Ok("No messages".to_string()); + } + + let content = std::fs::read_to_string(&self.message_file)?; + let recent: String = content + .lines() + .rev() + .take(lines) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("\n"); + + Ok(recent) + } + + fn wrap_text(text: &str, width: usize) -> String { + text.chars() + .collect::>() + .chunks(width) + .map(|chunk| chunk.iter().collect::()) + .collect::>() + .join("\n║ ") + } +} + +impl Default for TerminalWriter { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_terminal_writer() { + let writer = TerminalWriter::new(); + let result = writer.write_to_terminal("Test message", "test-agent"); + assert!(result.is_ok()); + } + + #[test] + fn test_wrap_text() { + let wrapped = TerminalWriter::wrap_text("Hello World", 5); + assert!(wrapped.contains("Hello")); + assert!(wrapped.contains("Worl")); + assert!(wrapped.contains("d")); + } +} diff --git a/src/traits.rs b/src/traits.rs index b9eed5c..1935f4e 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -1,9 +1,14 @@ use crate::types::FeedbackEntry; use async_trait::async_trait; +use std::process::Stdio; #[async_trait] pub trait CommandRunner: Send + Sync { - async fn run(&self, cmd: &str) -> Result; + async fn run(&self, program: &str, args: &[&str]) -> Result; + #[allow(dead_code)] + fn working_dir(&self) -> Option<&std::path::Path> { + None + } } #[async_trait] @@ -11,29 +16,36 @@ pub trait FeedbackCollector: Send + Sync { async fn collect(&self) -> Vec; } -#[async_trait] -pub trait SecurityScanner: Send + Sync { - async fn scan(&self, files: &[String]) -> Vec; +pub struct DefaultCommandRunner { + working_dir: Option, } -#[async_trait] -pub trait CodeAnalyzer: Send + Sync { - async fn analyze(&self, file: &str) -> (Vec, Vec); +impl DefaultCommandRunner { + pub fn new() -> Self { + Self { working_dir: None } + } + pub fn new_with_dir(dir: std::path::PathBuf) -> Self { + Self { + working_dir: Some(dir), + } + } } -#[async_trait] -pub trait IntegrityChecker: Send + Sync { - async fn check(&self) -> Result; +impl Default for DefaultCommandRunner { + fn default() -> Self { + Self::new() + } } -pub struct DefaultCommandRunner; - #[async_trait] impl CommandRunner for DefaultCommandRunner { - async fn run(&self, cmd: &str) -> Result { - let output = tokio::process::Command::new("sh") - .arg("-c") - .arg(cmd) + async fn run(&self, program: &str, args: &[&str]) -> Result { + let mut cmd = tokio::process::Command::new(program); + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); + if let Some(ref dir) = self.working_dir { + cmd.current_dir(dir); + } + let output = cmd .output() .await .map_err(|e| format!("Failed to run command: {}", e))?; @@ -42,6 +54,10 @@ impl CommandRunner for DefaultCommandRunner { let stderr = String::from_utf8_lossy(&output.stderr).to_string(); Ok(format!("{}\n{}", stdout, stderr)) } + + fn working_dir(&self) -> Option<&std::path::Path> { + self.working_dir.as_deref() + } } pub struct CompositeFeedbackCollector { diff --git a/src/types.rs b/src/types.rs index 7cd7902..dc609d8 100644 --- a/src/types.rs +++ b/src/types.rs @@ -4,9 +4,8 @@ use serde::{Deserialize, Serialize}; pub struct OuraConfig { pub max_iterations: u32, pub convergence_threshold: f64, - pub feedback_sources: Vec, - pub sync_to_synapsis: bool, - pub working_directory: String, + pub max_runtime_secs: u64, + pub working_directory: Option, } impl Default for OuraConfig { @@ -14,40 +13,12 @@ impl Default for OuraConfig { Self { max_iterations: 20, convergence_threshold: 90.0, - feedback_sources: vec![ - FeedbackSource { - type_: "test".into(), - command: Some("cargo test".into()), - enabled: true, - }, - FeedbackSource { - type_: "lint".into(), - command: Some("cargo clippy".into()), - enabled: true, - }, - FeedbackSource { - type_: "typecheck".into(), - command: None, - enabled: false, - }, - ], - sync_to_synapsis: true, - working_directory: std::env::current_dir() - .unwrap_or_default() - .to_string_lossy() - .into_owned(), + max_runtime_secs: 3600, + working_directory: None, } } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FeedbackSource { - #[serde(rename = "type")] - pub type_: String, - pub command: Option, - pub enabled: bool, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IterationResult { pub iteration: u32, @@ -140,5 +111,79 @@ pub struct JsonRpcError { pub struct McpToolDefinition { pub name: String, pub description: String, + #[serde(rename = "inputSchema")] pub input_schema: serde_json::Value, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_json_rpc_request_serialization() { + let req = JsonRpcRequest { + jsonrpc: "2.0".into(), + id: serde_json::Value::Number(1.into()), + method: "tools/list".into(), + params: None, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"jsonrpc\":\"2.0\"")); + assert!(json.contains("\"method\":\"tools/list\"")); + } + + #[test] + fn test_json_rpc_response_error() { + let resp = JsonRpcResponse { + jsonrpc: "2.0".into(), + id: serde_json::Value::Null, + result: None, + error: Some(JsonRpcError { + code: -32601, + message: "Method not found".into(), + data: None, + }), + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("\"code\":-32601")); + assert!(json.contains("\"message\":\"Method not found\"")); + } + + #[test] + fn test_json_rpc_response_success() { + let resp = JsonRpcResponse { + jsonrpc: "2.0".into(), + id: serde_json::Value::Number(1.into()), + result: Some(serde_json::json!({"tools": []})), + error: None, + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("\"result\"")); + assert!(!json.contains("\"error\"")); + } + + #[test] + fn test_mcp_tool_definition() { + let tool = McpToolDefinition { + name: "test_tool".into(), + description: "A test tool".into(), + input_schema: serde_json::json!({"type": "object", "properties": {}}), + }; + assert_eq!(tool.name, "test_tool"); + } + + #[test] + fn test_iteration_result_defaults() { + let result = IterationResult { + iteration: 1, + status: "running".into(), + started_at: "2024-01-01".into(), + completed_at: None, + actions: vec![], + feedback: vec![], + score: 100.0, + }; + assert_eq!(result.iteration, 1); + assert!(result.actions.is_empty()); + } +}