From d570ca5444788bf900229c55ef0a985c96e2e86f Mon Sep 17 00:00:00 2001 From: taggart_comet Date: Sun, 11 Jan 2026 12:12:58 +0200 Subject: [PATCH] init2 --- .gitignore | 7 + Cargo.lock | 1486 +++++++++++++++++++++++++ Cargo.toml | 46 + cats/__pycache__/cat.cpython-314.pyc | Bin 0 -> 2743 bytes cats/cat.py | 24 + cats/test_cat.py | 48 + src/domain/mod.rs | 12 + src/domain/project.rs | 40 + src/domain/prompting/general.rs | 106 ++ src/domain/prompting/mod.rs | 7 + src/domain/prompting/session.rs | 37 + src/domain/prompting/tools.rs | 19 + src/domain/session/mod.rs | 7 + src/domain/session/service.rs | 98 ++ src/domain/session/session.rs | 74 ++ src/domain/session/session_request.rs | 64 ++ src/domain/startup/mod.rs | 20 + src/domain/startup/service.rs | 244 ++++ src/domain/tools/change/insert.rs | 56 + src/domain/tools/change/mod.rs | 48 +- src/domain/tools/change/remove.rs | 51 +- src/domain/tools/change/replace.rs | 60 +- src/domain/tools/find_files.rs | 31 + src/domain/tools/finish.rs | 26 + src/domain/tools/list_objects.rs | 203 +++- src/domain/tools/mod.rs | 87 ++ src/domain/tools/read_objects.rs | 302 ++++- src/domain/tools/structure.rs | 26 + src/domain/workflow/chain.rs | 243 ++++ src/domain/workflow/mod.rs | 73 ++ src/domain/workflow/plan.rs | 0 src/domain/workflow/step.rs | 58 + src/domain/workflow/toolset.rs | 92 ++ src/domain/workflow/workflow.rs | 128 +++ src/infrastructure/db.rs | 128 +++ src/infrastructure/inference.rs | 219 ++++ src/infrastructure/init.rs | 96 ++ src/infrastructure/mod.rs | 9 + src/infrastructure/model_registry.rs | 75 ++ src/lib.rs | 9 + src/main.rs | 167 +++ src/repository/meta.rs | 56 + src/repository/mod.rs | 9 + src/repository/projects.rs | 108 ++ src/repository/session_requests.rs | 240 ++++ src/repository/sessions.rs | 108 ++ src/utils/io/file_operations.rs | 246 ++++ src/utils/io/mod.rs | 3 + src/utils/mod.rs | 7 + src/utils/parsing/heuristics.rs | 559 ++++++++++ src/utils/parsing/mod.rs | 7 + src/utils/parsing/parser.rs | 205 ++++ src/utils/parsing/tree_sitter.rs | 671 +++++++++++ src/utils/shortcuts.rs | 28 + 54 files changed, 6704 insertions(+), 69 deletions(-) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 cats/__pycache__/cat.cpython-314.pyc create mode 100644 cats/cat.py create mode 100644 cats/test_cat.py create mode 100644 src/domain/mod.rs create mode 100644 src/domain/project.rs create mode 100644 src/domain/prompting/general.rs create mode 100644 src/domain/prompting/mod.rs create mode 100644 src/domain/session/mod.rs create mode 100644 src/domain/session/service.rs create mode 100644 src/domain/session/session.rs create mode 100644 src/domain/session/session_request.rs create mode 100644 src/domain/tools/change/insert.rs create mode 100644 src/domain/tools/find_files.rs create mode 100644 src/domain/tools/mod.rs create mode 100644 src/domain/tools/structure.rs create mode 100644 src/domain/workflow/chain.rs delete mode 100644 src/domain/workflow/plan.rs create mode 100644 src/infrastructure/db.rs create mode 100644 src/infrastructure/inference.rs create mode 100644 src/infrastructure/init.rs create mode 100644 src/infrastructure/mod.rs create mode 100644 src/infrastructure/model_registry.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/repository/meta.rs create mode 100644 src/repository/mod.rs create mode 100644 src/repository/projects.rs create mode 100644 src/repository/session_requests.rs create mode 100644 src/repository/sessions.rs create mode 100644 src/utils/io/file_operations.rs create mode 100644 src/utils/io/mod.rs create mode 100644 src/utils/mod.rs create mode 100644 src/utils/parsing/heuristics.rs create mode 100644 src/utils/parsing/mod.rs create mode 100644 src/utils/parsing/parser.rs create mode 100644 src/utils/parsing/tree_sitter.rs create mode 100644 src/utils/shortcuts.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e47d786 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +/.idea +/.venv +venv/ +/.claude +/models/ +./models/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b9f1562 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1486 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "cc" +version = "1.2.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "ctrlc" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790" +dependencies = [ + "dispatch2", + "nix 0.30.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "drastis" +version = "0.1.0" +dependencies = [ + "clap", + "ctrlc", + "directories", + "env_logger", + "llama-cpp-2", + "log", + "num_cpus", + "regex", + "rusqlite", + "rustyline", + "serde", + "serde_yaml", + "tempfile", + "thiserror", + "tree-sitter", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-cpp", + "tree-sitter-css", + "tree-sitter-go", + "tree-sitter-html", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-json", + "tree-sitter-md", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-toml-ng", + "tree-sitter-typescript", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" + +[[package]] +name = "find_cuda_helper" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f9e65c593dd01ac77daad909ea4ad17f0d6d1776193fc8ea766356177abdad" +dependencies = [ + "glob", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jiff" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.179" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "llama-cpp-2" +version = "0.1.131" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a256ab71761e7c46a24e21fe7d923e1e6b78d465f41c897277f79477da96b84" +dependencies = [ + "enumflags2", + "llama-cpp-sys-2", + "thiserror", + "tracing", + "tracing-core", +] + +[[package]] +name = "llama-cpp-sys-2" +version = "0.1.131" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff5b2268a06180a67d8a1d3dd9d1f1c70d52de7b6f40f967ec4eabd90d0417ae" +dependencies = [ + "bindgen", + "cc", + "cmake", + "find_cuda_helper", + "glob", + "walkdir", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +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 = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustyline" +version = "15.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f" +dependencies = [ + "bitflags", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix 0.29.0", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.59.0", +] + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "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 = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tree-sitter" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-bash" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afd2b1bf1585dc2ef6d69e87d01db8adb059006649dd5f96f31aa789ee6e9c71" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-css" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-html" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-json" +version = "0.24.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae62f7eae5eb549c71b76658648b72cc6111f2d87d24a1e31fa907f4943e3ce" + +[[package]] +name = "tree-sitter-md" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f968c22a01010b83fc960455ae729db08dbeb6388617d9113897cb9204b030" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-toml-ng" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[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-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 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_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[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_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "zerocopy" +version = "0.8.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fabae64378cb18147bb18bca364e63bdbe72a0ffe4adf0addfec8aa166b2c56" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c2d862265a8bb4471d87e033e730f536e2a285cc7cb05dbce09a2a97075f90" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3e2021d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "drastis" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "drastis" +path = "src/main.rs" + +[lib] +name = "drastis" +path = "src/lib.rs" + +[dependencies] +llama-cpp-2 = "0.1" +num_cpus = "1.16" +clap = { version = "4", features = ["derive"] } +rustyline = "15" +directories = "5" +rusqlite = { version = "0.32", features = ["bundled"] } +serde = { version = "1.0", features = ["derive"] } +serde_yaml = "0.9" +log = "0.4" +env_logger = "0.11" +tree-sitter = "0.24" +tree-sitter-rust = "0.23" +tree-sitter-python = "0.23" +tree-sitter-javascript = "0.23" +tree-sitter-typescript = "0.23" +tree-sitter-go = "0.23" +tree-sitter-java = "0.23" +tree-sitter-c = "0.23" +tree-sitter-cpp = "0.23" +tree-sitter-ruby = "0.23" +tree-sitter-json = "0.24" +tree-sitter-toml-ng = "0.7" +tree-sitter-html = "0.23" +tree-sitter-css = "0.23" +tree-sitter-bash = "0.23" +tree-sitter-md = "0.3" +regex = "1.10" +thiserror = "1.0" +ctrlc = "3.4" + +[dev-dependencies] +tempfile = "3.0" diff --git a/cats/__pycache__/cat.cpython-314.pyc b/cats/__pycache__/cat.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..88ed35352090eac9d53e7b89c8959fea6d5647e9 GIT binary patch literal 2743 zcmcIm&u<$=6rS0&olWAzp=pygZDEs20k>5w+N4TQRUwc#L7Hx%MQZ4>*qe62_J)~p zQGD_Ni6Sa!1X5dxLn|Sn{1)rLu&30-uvF0 z_x9B2$Ph!DxpO`E-eBx6n)F(xJ7~{h&|-(!G;`P_bND<~Qd7(^FgGVzbxd(5bAlDK zTy11~g|-bA~j>a)#C56{d!qJhU2#&9^;O=FoyZYGj;=PaAihx1h>(WARJID&QVt zm-tO6c9~y;iJ-h>;2867GAPc}y_z4J-VMKK#HLgtHiNptIJXvxny2Cs*DY5)DJwx) zxvr#5Gp%^3^nvt+EY-ZvWKdIHAj4ZFr-A+9EnlpN@Ke96WT_%NsRCIldzgUCt#8D6 z*Y)c4PgI6ZV8jD;zoCRzUEVT94gxU%5@%{*SSgxuM*7vYlrQPl z+1XiKi;&e}F4 z&+}dh`feE_i)C?6_^Ki5TU=O}77an6FNQ(7F478$5#o-5kWKO+h=!GCPd;WKn`Pal zI$?oseLQVUDV|mm{vMhKo@XeQcr$NHZ$rLezvqWvOc%Mv3%b#TlF;wsm8n4)v4#b|Amt{j9bMN$bVx)(52WI*>jH>2w{UR)dQ<^ z>ti#AX)B4Tu80v*B!8*_DXx(+;_OXteSM>O*bb!adn%~kn6sC|nq5!6X*ie=Nh0nT zMtksE^ZiPKAC0u)^j0X&j^557(t0pP$L6ELLS!wZ`oC}-Db7+H&Flg_)`iW>x=Z!I zD&6`xevIjUG3Kzv50S^lD33iK+kK^mM&1!texe+<8}Ju!7J*bMGodpH`6 zm!f=Wmru?{h55*uPqBF6KA~8WlOWx9zryLoukZ&al0H=hYjmR&s8i|t80_a3diwhU z&LPPphwY}=W1PuR>1pOD`aQcjVQ=`Ju)}J_E_=#GOjq$B?3mAq1K9cu{#M18Wf6Sh zC;7HF(?-6g)$N0Q|DA{PKQBI7e0VZC{njt@zb*Z`^vkK=FGYomk#$j9T{Ly3PoLXz zahNhXRYYX;A=j;il}44us9B#kylPicaNV^)NCjQ}dg!|15b1iH$O$5^5}`sAA40`M zp$p2crxf&VAk1*wOS@`n*{@bz7b#QcZiU@j+IXrq>eRCno9MRV=qo~kPdf66B@J;>*&L=^0K`cV9T{nTjj>$Ri7~`DY LAJNX~o+ str: + return f"{self.name} says: Meow!" + + def feed(self) -> str: + self.mood = "happy" + return f"{self.name} is eating. Nom nom nom!" + + def pet(self) -> str: + if self.mood == "happy": + return f"{self.name} purrs loudly." + return f"{self.name} tolerates the petting." + + def describe(self) -> str: + return f"{self.name} is a {self.age} year old cat feeling {self.mood}." + + +def create_cat(name: str, age: int) -> Cat: + return Cat(name, age) \ No newline at end of file diff --git a/cats/test_cat.py b/cats/test_cat.py new file mode 100644 index 0000000..6a81274 --- /dev/null +++ b/cats/test_cat.py @@ -0,0 +1,48 @@ +import pytest +from cat import Cat, create_cat + + +def test_cat_creation(): + cat = Cat("Whiskers", 3) + assert cat.name == "Whiskers" + assert cat.age == 3 + assert cat.mood == "neutral" + + +def test_meow(): + cat = Cat("Luna", 2) + assert cat.meow() == "Luna says: Meow!" + + +def test_feed_changes_mood(): + cat = Cat("Oliver", 5) + assert cat.mood == "neutral" + cat.feed() + assert cat.mood == "happy" + + +def test_pet_when_neutral(): + cat = Cat("Milo", 1) + result = cat.pet() + assert "tolerates" in result + + +def test_pet_when_happy(): + cat = Cat("Bella", 4) + cat.feed() + result = cat.pet() + assert "purrs" in result + + +def test_describe(): + cat = Cat("Max", 7) + desc = cat.describe() + assert "Max" in desc + assert "7" in desc + assert "neutral" in desc + + +def test_create_cat_helper(): + cat = create_cat("Simba", 2) + assert isinstance(cat, Cat) + assert cat.name == "Simba" diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..ae183c8 --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,12 @@ +pub mod prompting; +pub mod tools; +pub mod workflow; +pub mod session; +pub mod startup; +mod project; + +pub use project::Project; +pub use session::{Session, SessionRequest}; +pub use session::service::SessionService; +pub use startup::{StartupService, StartupConfig}; +pub use workflow::{Workflow, Chain, CancellationToken}; diff --git a/src/domain/project.rs b/src/domain/project.rs new file mode 100644 index 0000000..cf27d42 --- /dev/null +++ b/src/domain/project.rs @@ -0,0 +1,40 @@ +use crate::repository::ProjectRow; + +/// Domain entity representing a project. +/// A project groups related sessions together, typically scoped to a directory. +#[derive(Debug, Clone)] +pub struct Project { + id: i64, + name: String, + created_at: u64, + session_count: u64, +} + +impl Project { + pub fn id(&self) -> i64 { + self.id + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn created_at(&self) -> u64 { + self.created_at + } + + pub fn session_count(&self) -> u64 { + self.session_count + } +} + +impl From for Project { + fn from(row: ProjectRow) -> Self { + Self { + id: row.id, + name: row.name, + created_at: row.created_at.parse().unwrap_or(0), + session_count: row.session_count as u64, + } + } +} diff --git a/src/domain/prompting/general.rs b/src/domain/prompting/general.rs new file mode 100644 index 0000000..cdd4fb4 --- /dev/null +++ b/src/domain/prompting/general.rs @@ -0,0 +1,106 @@ +use crate::domain::workflow::Chain; +use crate::domain::workflow::Toolset; + +/// LLM prompt templates for the coding assistant +/// +/// This module contains all prompt templates used for LLM interactions. + +/// Create a prompt for the LLM to choose the next tool +pub fn tool_selection_prompt( + user_prompt: &str, + toolset: &Toolset, + chain: &Chain, +) -> String { + format!( + "<|im_start|>system\nYou are a coding assistant. Choose ONE next tool to use from the available tools to accomplish the user's request.\n\n{}\n\n<|im_end|>\n\ + <|im_start|>user\n{}---\n{}\n<|im_end|>\n\ + \nrespond in a valid yaml format of the chosen tool, specifying input values for the tool, according to it's interface\n<\\output_format>\n\ + <|im_start|>assistant\n", + toolset.get_tools_description(), + _format_chain_context(chain), + user_prompt, + ) +} + +fn _format_chain_context(chain: &Chain) -> String { + use crate::domain::workflow::step::StepType; + + let mut context = String::new(); + context.push_str("previous_tool_calls:\n"); + + // should be in yaml format like: + // previous_tool_calls: + // - tool_name: name + // execution_order: 1 # autoincremented + // status: successful/error + // input: Yaml + // output: Yaml + let mut execution_order = 0; + for step in chain.steps() { + // Only include tool_call steps, skip interruptions and other step types + if step.step_type != StepType::ToolCall.as_str() { + continue; + } + + execution_order += 1; + + // Extract tool_name from summary (format: "Tool `name` was executed successfully" or "Tool `name` failed: error") + let tool_name = if let Some(start) = step.summary.find('`') { + if let Some(end) = step.summary[start + 1..].find('`') { + &step.summary[start + 1..start + 1 + end] + } else { + "unknown" + } + } else { + "unknown" + }; + + // Determine status from summary + let status = if step.summary.contains("successfully") { + "successful" + } else if step.summary.contains("failed") { + "error" + } else { + "unknown" + }; + + // Format as YAML + context.push_str(&format!("- tool_name: {}\n", tool_name)); + context.push_str(&format!(" execution_order: {}\n", execution_order)); + context.push_str(&format!(" status: {}\n", status)); + + // Format input as YAML (handle multi-line with proper indentation) + let input_str = step.input_payload.trim(); + if input_str.is_empty() { + context.push_str(" input: null\n"); + } else if input_str.contains('\n') || input_str.starts_with('{') || input_str.starts_with('[') { + // Multi-line or complex YAML - use literal block scalar + context.push_str(" input: |\n"); + for line in input_str.lines() { + context.push_str(&format!(" {}\n", line)); + } + } else { + // Single line - can be inline + context.push_str(&format!(" input: {}\n", input_str)); + } + + // Format output as YAML (handle multi-line with proper indentation) + let output_str = step.context_payload.trim(); + if output_str.is_empty() { + context.push_str(" output: null\n"); + } else if output_str.contains('\n') || output_str.starts_with('{') || output_str.starts_with('[') { + // Multi-line or complex YAML - use literal block scalar + context.push_str(" output: |\n"); + for line in output_str.lines() { + context.push_str(&format!(" {}\n", line)); + } + } else { + // Single line - can be inline + context.push_str(&format!(" output: {}\n", output_str)); + } + } + + context +} + + diff --git a/src/domain/prompting/mod.rs b/src/domain/prompting/mod.rs new file mode 100644 index 0000000..a3c9dfb --- /dev/null +++ b/src/domain/prompting/mod.rs @@ -0,0 +1,7 @@ +mod general; +mod session; +mod tools; + +pub use general::*; +pub use session::*; +pub use tools::*; diff --git a/src/domain/prompting/session.rs b/src/domain/prompting/session.rs index e69de29..535ab4d 100644 --- a/src/domain/prompting/session.rs +++ b/src/domain/prompting/session.rs @@ -0,0 +1,37 @@ +use crate::domain::Session; + +/// Format session history and current request as a complete prompt +pub fn format_session_prompt(session: &Session) -> String { + let mut formatted = String::new(); + + // Add history section + formatted.push_str("previous requests history:\n"); + + if session.requests().is_empty() { + formatted.push_str(" (no previous requests)\n"); + } else { + for request in session.requests() { + formatted.push_str(&format!(" - request: {}\n", request.prompt())); + if let Some(result) = request.result_summary() { + formatted.push_str(&format!(" result: {}\n", result)); + } else { + formatted.push_str(" result: (no result yet)\n"); + } + } + } + + // Add current request + formatted.push_str(&format!("CURRENT REQUEST: {}", session.current_request())); + + formatted +} + +/// Create a prompt for generating a short session name +pub fn session_naming_prompt(prompt_preview: &str) -> String { + format!( + "<|im_start|>system\nYou generate very short titles (3-5 words). Respond with only the title.<|im_end|>\n\ + <|im_start|>user\nTitle for: {}<|im_end|>\n\ + <|im_start|>assistant\n", + prompt_preview + ) +} \ No newline at end of file diff --git a/src/domain/prompting/tools.rs b/src/domain/prompting/tools.rs index e69de29..0ed4876 100644 --- a/src/domain/prompting/tools.rs +++ b/src/domain/prompting/tools.rs @@ -0,0 +1,19 @@ +use crate::domain::Chain; +use crate::domain::tools::Tool; +use crate::domain::workflow::Toolset; + +/// Format available tools as a description for the LLM +/// +/// Generates a formatted list of all available tools with their descriptions +/// and input formats +pub fn format_tools_description<'a>(tools: impl Iterator) -> String { + let mut description = String::from("Available Tools:\n\n"); + + for (name, tool) in tools { + description.push_str(&format!("Description: {}\n", tool.desc())); + description.push_str("Format:\n"); + description.push_str(&format!("```yaml\ntool_name: {}{}```\n\n", name, tool.input_format())); + } + description +} + diff --git a/src/domain/session/mod.rs b/src/domain/session/mod.rs new file mode 100644 index 0000000..78d96ee --- /dev/null +++ b/src/domain/session/mod.rs @@ -0,0 +1,7 @@ +pub mod session; +pub mod session_request; +pub mod service; + +pub use session::Session; +pub use session_request::SessionRequest; +pub use service::{SessionService, ServiceError}; diff --git a/src/domain/session/service.rs b/src/domain/session/service.rs new file mode 100644 index 0000000..40d9d85 --- /dev/null +++ b/src/domain/session/service.rs @@ -0,0 +1,98 @@ +use crate::domain::workflow::{Workflow, Chain, CancellationToken, Error as WorkflowError}; +use super::session::Session; +use crate::infrastructure::inference::InferenceEngine; +use crate::repository::SessionRequestsRepository; +use rusqlite::Connection; + +/// Service for running workflows on sessions +pub struct SessionService { + workflow: Workflow, +} + +impl SessionService { + /// Create a new session service with default workflow + pub fn new() -> Self { + Self { + workflow: Workflow::new(), + } + } + + /// Create a session service with a custom workflow + pub fn with_workflow(workflow: Workflow) -> Self { + Self { workflow } + } + + /// Run the workflow for a new request in the given session + /// This is the main entry point for executing a coding task + /// Creates a new session_request, runs the workflow, and updates the request with the result + /// Returns the execution chain + pub fn run( + &self, + session: &Session, + prompt: &str, + engine: &InferenceEngine, + cancel: &CancellationToken, + conn: &Connection, + ) -> Result { + // Create a new session request + let requests_repo = SessionRequestsRepository::new(conn); + let request_row = requests_repo.create(session.id(), prompt) + .map_err(|e| ServiceError::Repository(e))?; + let request_id = request_row.id; + + // Update session with current request for workflow execution + // We need a mutable session, but we only have a reference + // The workflow uses session.current_prompt() which needs the current_request set + // For now, we'll create a temporary session with the current request set + let mut session_with_request = session.clone(); + session_with_request.set_current_request(prompt.to_string()); + + // Run the workflow + let result = match self.workflow.run(&session_with_request, engine, cancel) { + Ok(chain) => { + // Get summary and log from chain + let summary = chain.get_summary(); + let steps_log = chain.get_log(); + + // Update the request with result_summary and steps_log + requests_repo.update_result(request_id, &summary) + .map_err(|e| ServiceError::Repository(e))?; + requests_repo.update_steps_log(request_id, &steps_log) + .map_err(|e| ServiceError::Repository(e))?; + + Ok(chain) + } + Err(e) => { + // Create a chain with error + let mut chain = Chain::new(); + chain.mark_failed(format!("Error: {}", e)); + + let summary = chain.get_summary(); + let steps_log = chain.get_log(); + + requests_repo.update_result(request_id, &summary) + .map_err(|e| ServiceError::Repository(e))?; + requests_repo.update_steps_log(request_id, &steps_log) + .map_err(|e| ServiceError::Repository(e))?; + + Err(ServiceError::Workflow(e)) + } + }; + + result + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ServiceError { + #[error("workflow error: {0}")] + Workflow(WorkflowError), + #[error("repository error: {0}")] + Repository(String), +} + +impl Default for SessionService { + fn default() -> Self { + Self::new() + } +} diff --git a/src/domain/session/session.rs b/src/domain/session/session.rs new file mode 100644 index 0000000..a35dd15 --- /dev/null +++ b/src/domain/session/session.rs @@ -0,0 +1,74 @@ +use super::session_request::SessionRequest; +use crate::repository::SessionRow; + +/// Domain entity representing a conversation session. +/// A session belongs to a project and contains a named conversation history. +#[derive(Debug, Clone)] +pub struct Session { + id: i64, + project_id: i64, + name: String, + created_at: u64, + requests: Vec, + current_request: String, +} + +impl Session { + pub fn id(&self) -> i64 { + self.id + } + + pub fn project_id(&self) -> i64 { + self.project_id + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn created_at(&self) -> u64 { + self.created_at + } + + pub fn requests(&self) -> &[SessionRequest] { + &self.requests + } + + pub fn add_request(&mut self, request: SessionRequest) { + self.requests.push(request); + } + + pub fn set_current_request(&mut self, prompt: String) { + self.current_request = prompt; + } + + pub fn current_request(&self) -> &str { + &self.current_request + } + + pub fn set_requests(&mut self, requests: Vec) { + self.requests = requests; + } + + pub fn load_with_requests( + session_row: crate::repository::SessionRow, + requests: Vec + ) -> Self { + let mut session = Self::from(session_row); + session.set_requests(requests); + session + } +} + +impl From for Session { + fn from(row: SessionRow) -> Self { + Self { + id: row.id, + project_id: row.project_id, + name: row.name, + created_at: row.created_at.parse().unwrap_or(0), + requests: Vec::new(), + current_request: String::new(), + } + } +} diff --git a/src/domain/session/session_request.rs b/src/domain/session/session_request.rs new file mode 100644 index 0000000..adefda9 --- /dev/null +++ b/src/domain/session/session_request.rs @@ -0,0 +1,64 @@ +use crate::repository::SessionRequestRow; + +/// Domain entity representing a user request within a session. +/// Each request contains the user's prompt and the resulting summary. +#[derive(Debug, Clone)] +pub struct SessionRequest { + id: i64, + session_id: i64, + prompt: String, + result_summary: Option, + created_at: u64, +} + +impl SessionRequest { + pub fn new(session_id: i64, prompt: String) -> Self { + Self { + id: 0, // Will be set when saved to database + session_id, + prompt, + result_summary: None, + created_at: 0, // Will be set when saved to database + } + } + + pub fn id(&self) -> i64 { + self.id + } + + pub fn session_id(&self) -> i64 { + self.session_id + } + + pub fn prompt(&self) -> &str { + &self.prompt + } + + pub fn result_summary(&self) -> Option<&str> { + self.result_summary.as_deref() + } + + pub fn created_at(&self) -> u64 { + self.created_at + } + + pub fn set_result_summary(&mut self, summary: String) { + self.result_summary = Some(summary); + } + + pub fn has_result(&self) -> bool { + self.result_summary.is_some() + } +} + +impl From for SessionRequest { + fn from(row: SessionRequestRow) -> Self { + Self { + id: row.id, + session_id: row.session_id, + prompt: row.prompt, + result_summary: row.result_summary, + created_at: row.created_at.parse().unwrap_or(0), + } + } +} diff --git a/src/domain/startup/mod.rs b/src/domain/startup/mod.rs index e69de29..5818c58 100644 --- a/src/domain/startup/mod.rs +++ b/src/domain/startup/mod.rs @@ -0,0 +1,20 @@ +mod service; + +pub use service::{StartupService, StartupConfig}; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("repository error: {0}")] + Repository(String), + + #[error("session not found: {0}")] + SessionNotFound(i64), +} + +impl From for String { + fn from(err: Error) -> Self { + err.to_string() + } +} \ No newline at end of file diff --git a/src/domain/startup/service.rs b/src/domain/startup/service.rs index e69de29..787bc74 100644 --- a/src/domain/startup/service.rs +++ b/src/domain/startup/service.rs @@ -0,0 +1,244 @@ +use super::Error; +use crate::domain::prompting::session_naming_prompt; +use crate::domain::{Project, Session, SessionRequest}; +use crate::infrastructure::inference::InferenceEngine; +use crate::repository::{ProjectsRepository, SessionsRepository, SessionRequestsRepository}; +use rusqlite::Connection; +use std::env; + +/// Configuration for the startup service +#[derive(Debug, Clone)] +pub struct StartupConfig { + pub debug: bool, +} + +/// Domain service responsible for creating sessions and managing domain logic +/// This service operates on already-initialized infrastructure components +pub struct StartupService { + config: StartupConfig, +} + +impl StartupService { + /// Create a new startup service with the given configuration + pub fn new(config: StartupConfig) -> Self { + Self { config } + } + + /// Create a startup service with debug configuration + pub fn with_debug(debug: bool) -> Self { + Self::new(StartupConfig { debug }) + } + + /// Create a new session with the given first prompt + /// This is a pure domain operation that uses infrastructure components + /// + /// # Arguments + /// * `conn` - Database connection (infrastructure) + /// * `engine` - Inference engine (infrastructure) + /// * `first_prompt` - The initial user prompt + /// + /// # Returns + /// A ready Session entity with the first request and conversation history + pub fn start( + &self, + conn: &Connection, + engine: &InferenceEngine, + first_prompt: &str, + ) -> Result { + // 1. Get or create project based on current directory name + let project = self.init_project(conn)?; + + // 2. Create session with the first prompt + self.create_session(conn, engine, &project, first_prompt) + } + + /// Initialize or load a project based on the current directory + fn init_project(&self, conn: &Connection) -> Result { + let project_name = env::current_dir() + .ok() + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) + .unwrap_or_else(|| "default".to_string()); + + let repo = ProjectsRepository::new(conn); + let (row, created) = repo.get_or_create(&project_name) + .map_err(Error::Repository)?; + + // Map repository row to domain entity + let project = Project::from(row); + + if created { + log::info!("Project created: {} (id={})", project.name(), project.id()); + } else { + log::info!( + "Project loaded: {} (id={}, sessions={})", + project.name(), + project.id(), + project.session_count() + ); + } + + Ok(project) + } + + /// Create a new session with the given first prompt + fn create_session( + &self, + conn: &Connection, + engine: &InferenceEngine, + project: &Project, + first_prompt: &str, + ) -> Result { + // Generate session name from the first prompt using chat format + let prompt_preview: String = first_prompt.chars().take(100).collect(); + let naming_prompt = session_naming_prompt(&prompt_preview); + + let session_name = match engine.generate_silent(&naming_prompt, 15) { + Ok(raw) => { + log::debug!("Raw session name response: {:?}", raw); + // Clean up the response + let cleaned = raw + .lines() + .next() + .unwrap_or("") + .trim() + .trim_matches('"') + .trim_matches('*') + .replace("<|im_end|>", "") + .trim() + .to_string(); + + if cleaned.is_empty() || cleaned.len() > 50 { + Self::fallback_session_name(first_prompt) + } else { + cleaned + } + } + Err(e) => { + log::warn!("Session naming failed: {}", e); + Self::fallback_session_name(first_prompt) + } + }; + + // Create session in database + let sessions_repo = SessionsRepository::new(conn); + let row = sessions_repo.create(project.id(), &session_name) + .map_err(Error::Repository)?; + + // Increment project session count + let projects_repo = ProjectsRepository::new(conn); + projects_repo.increment_session_count(project.id()) + .map_err(Error::Repository)?; + + // Create session without requests (requests will be created by session_service.run()) + let session = Session::from(row); + + log::info!("Session created: \"{}\" (id={})", session.name(), session.id()); + + Ok(session) + } + + /// Generate a fallback session name from the prompt + fn fallback_session_name(prompt: &str) -> String { + // Use first few words of the prompt as fallback + let words: Vec<&str> = prompt.split_whitespace().take(5).collect(); + let name = words.join(" "); + if name.len() > 40 { + format!("{}...", &name[..37]) + } else if name.is_empty() { + "New Session".to_string() + } else { + name + } + } + + /// Load an existing session by ID with all its requests + pub fn load_session(&self, conn: &Connection, session_id: i64) -> Result { + let sessions_repo = SessionsRepository::new(conn); + let session_row = sessions_repo + .find_by_id(session_id) + .map_err(Error::Repository)? + .ok_or(Error::SessionNotFound(session_id))?; + + let requests_repo = SessionRequestsRepository::new(conn); + let request_rows = requests_repo.find_by_session(session_id) + .map_err(Error::Repository)?; + let requests: Vec = request_rows + .into_iter() + .map(SessionRequest::from) + .collect(); + + Ok(Session::load_with_requests(session_row, requests)) + } + + /// Add a new request to an existing session + pub fn add_request( + &self, + conn: &Connection, + session: &mut Session, + prompt: &str, + ) -> Result<(), Error> { + let requests_repo = SessionRequestsRepository::new(conn); + let request_row = requests_repo.create(session.id(), prompt) + .map_err(Error::Repository)?; + let request = SessionRequest::from(request_row); + session.add_request(request); + session.set_current_request(prompt.to_string()); + Ok(()) + } + + /// Update the result of the latest request in a session + pub fn update_latest_result( + &self, + conn: &Connection, + session: &mut Session, + result_summary: &str, + ) -> Result<(), Error> { + let requests_repo = SessionRequestsRepository::new(conn); + if let Some(latest_request) = requests_repo.find_latest_by_session(session.id()) + .map_err(Error::Repository)? + { + requests_repo.update_result(latest_request.id, result_summary) + .map_err(Error::Repository)?; + + // Reload the session to get the updated request + *session = self.load_session(conn, session.id())?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fallback_session_name() { + assert_eq!( + StartupService::fallback_session_name("Hello world test"), + "Hello world test" + ); + + // Test with a long prompt that will be truncated after taking 5 words + let result = StartupService::fallback_session_name("This is a very long prompt with many words that should be truncated"); + assert_eq!(result, "This is a very long"); // Only first 5 words, under 40 chars + + // Test with 5 words that exceed 40 characters + let result2 = StartupService::fallback_session_name("supercalifragilisticexpialidocious antidisestablishmentarianism pneumonoultramicroscopicsilicovolcanoconiosisword anotherverylongword"); + assert!(result2.len() <= 40); + assert!(result2.ends_with("...")); + + assert_eq!( + StartupService::fallback_session_name(""), + "New Session" + ); + } + + #[test] + fn test_startup_config() { + let config = StartupConfig { debug: false }; + assert!(!config.debug); + + let service = StartupService::with_debug(true); + assert!(service.config.debug); + } +} diff --git a/src/domain/tools/change/insert.rs b/src/domain/tools/change/insert.rs new file mode 100644 index 0000000..7bbbbeb --- /dev/null +++ b/src/domain/tools/change/insert.rs @@ -0,0 +1,56 @@ +use super::Error; +use crate::domain::tools::{Tool, ToolResult}; +use crate::utils::insert_content; +use serde::Deserialize; +use serde_yaml::Value as Yaml; + +pub struct Insert; + +#[derive(Deserialize)] +struct InsertInput { + full_path_to_file: String, + target_line: usize, + #[serde(default)] + insert_content: String, +} + +impl Insert { + fn parse_input(input: Yaml) -> Result { + serde_yaml::from_value(input).map_err(|e| Error::InvalidYaml(e.to_string())) + } +} + +impl Tool for Insert { + fn name(&self) -> &'static str { + "change_insert" + } + + fn work(&self, input: Yaml) -> ToolResult { + let input_copy = input.clone(); + + match Self::parse_input(input) { + Ok(parsed) => match insert_content( + &parsed.full_path_to_file, + parsed.target_line, + &parsed.insert_content, + ) { + Ok(()) => ToolResult::ok(self.name(), input_copy, Yaml::Null), + Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()), + }, + Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()), + } + } + + fn desc(&self) -> &'static str { + "Insert content at a specific line in an existing file" + } + + fn input_format(&self) -> &'static str { + " +input: + full_path_to_file: string + target_line: integer + insert_content: string +" + } +} diff --git a/src/domain/tools/change/mod.rs b/src/domain/tools/change/mod.rs index 0dfc098..d091abf 100644 --- a/src/domain/tools/change/mod.rs +++ b/src/domain/tools/change/mod.rs @@ -1,17 +1,37 @@ -pub mod change; -mod find_files; -mod read_file; -mod find_objects; -mod structure; +mod insert; +mod remove; +mod replace; -pub use find_files::FindFiles; -pub use find_objects::FindObjects; -pub use read_file::ReadFile; -pub use structure::Structure; +pub use insert::Insert; +pub use remove::Remove; +pub use replace::Replace; -pub trait Tool { - fn name(&self) -> &'static str; - fn work(&self, input: &str) -> &'static str; - fn desc(&self) -> &'static str; - fn format(&self) -> &'static str; +use serde::Serialize; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("invalid yaml: {0}")] + InvalidYaml(String), +} + +#[derive(Serialize)] +pub struct ChangeOutput { + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl ChangeOutput { + pub fn success() -> String { + serde_yaml::to_string(&Self { ok: true, error: None }).unwrap_or_else(|_| "ok: true\n".to_string()) + } + + pub fn failure(error: impl Into) -> String { + serde_yaml::to_string(&Self { + ok: false, + error: Some(error.into()), + }) + .unwrap_or_else(|e| format!("ok: false\nerror: {}\n", e)) + } } diff --git a/src/domain/tools/change/remove.rs b/src/domain/tools/change/remove.rs index ee8ee12..f3c27c1 100644 --- a/src/domain/tools/change/remove.rs +++ b/src/domain/tools/change/remove.rs @@ -1,28 +1,53 @@ -use tools::Tool; +use super::Error; +use crate::domain::tools::{Tool, ToolResult}; +use crate::utils::remove_lines; +use serde::Deserialize; +use serde_yaml::Value as Yaml; -pub struct Insert; +pub struct Remove; -impl Tool for Insert { +#[derive(Deserialize)] +struct RemoveInput { + full_path_to_file: String, + target_line: usize, + count: usize, +} + +impl Remove { + fn parse_input(input: Yaml) -> Result { + serde_yaml::from_value(input).map_err(|e| Error::InvalidYaml(e.to_string())) + } +} + +impl Tool for Remove { fn name(&self) -> &'static str { - "change" + "change_remove" } - fn work(&self, _input: &str) -> &'static str { - "" + fn work(&self, input: Yaml) -> ToolResult { + let input_copy = input.clone(); + + match Self::parse_input(input) { + Ok(parsed) => { + match remove_lines(&parsed.full_path_to_file, parsed.target_line, parsed.count) { + Ok(()) => ToolResult::ok(self.name(), input_copy, Yaml::Null), + Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()), + } + } + Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()), + } } fn desc(&self) -> &'static str { - "Apply an edit to an existing file" + "Remove a specified number of lines from a file starting at a target line" } - fn format(&self) -> &'static str { + fn input_format(&self) -> &'static str { " input: - target_line: string # line in the file where to insert the change - insert_content: string # content to insert -output: - ok: boolean - error: string # optional + full_path_to_file: string + target_line: integer + count: integer " } } diff --git a/src/domain/tools/change/replace.rs b/src/domain/tools/change/replace.rs index 85cd94b..02449df 100644 --- a/src/domain/tools/change/replace.rs +++ b/src/domain/tools/change/replace.rs @@ -1,29 +1,61 @@ -use crate::domain::tools::Tool; +use super::Error; +use crate::domain::tools::{Tool, ToolResult}; +use crate::utils::replace_lines; +use serde::Deserialize; +use serde_yaml::Value as Yaml; -pub struct Insert; +pub struct Replace; -impl Tool for Insert { +#[derive(Deserialize)] +struct ReplaceInput { + full_path_to_file: String, + start_line: usize, + end_line: usize, + #[serde(default)] + content: String, +} + +impl Replace { + fn parse_input(input: Yaml) -> Result { + serde_yaml::from_value(input).map_err(|e| Error::InvalidYaml(e.to_string())) + } +} + +impl Tool for Replace { fn name(&self) -> &'static str { - "change_insert" + "change_replace" } - fn work(&self, _input: &str) -> String { - String::new() + fn work(&self, input: Yaml) -> ToolResult { + let input_copy = input.clone(); + + match Self::parse_input(input) { + Ok(parsed) => { + match replace_lines( + &parsed.full_path_to_file, + parsed.start_line, + parsed.end_line, + &parsed.content, + ) { + Ok(()) => ToolResult::ok(self.name(), input_copy, Yaml::Null), + Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()), + } + } + Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()), + } } fn desc(&self) -> &'static str { - "Apply an edit to an existing file" + "Replace content between start_line and end_line (inclusive) with new content" } - fn format(&self) -> &'static str { + fn input_format(&self) -> &'static str { " input: - file_name: string # full path to the file - target_line: string # line in the file where to insert the change - insert_content: string # content to insert -output: - ok: boolean - error: string # optional + full_path_to_file: string + start_line: integer + end_line: integer + content: string " } } diff --git a/src/domain/tools/find_files.rs b/src/domain/tools/find_files.rs new file mode 100644 index 0000000..7348ddf --- /dev/null +++ b/src/domain/tools/find_files.rs @@ -0,0 +1,31 @@ +use crate::domain::tools::{Tool, ToolResult}; +use serde_yaml::Value as Yaml; + +pub struct FindFiles; + +impl Tool for FindFiles { + fn name(&self) -> &'static str { + "find_files" + } + + fn work(&self, input: Yaml) -> ToolResult { + ToolResult::ok( + self.name(), + input, + Yaml::String("/Users/maksimtaisov/RustroverProjects/drastis/cats/cat.py".to_string()), + ) + } + + fn desc(&self) -> &'static str { + "Find files under a root directory by substring match" + } + + fn input_format(&self) -> &'static str { + " +input: + query: string # substring to match against path/filename + root: string # optional, search root; default is project root + max_results: integer # optional, default 20 +" + } +} \ No newline at end of file diff --git a/src/domain/tools/finish.rs b/src/domain/tools/finish.rs index e69de29..e1101f3 100644 --- a/src/domain/tools/finish.rs +++ b/src/domain/tools/finish.rs @@ -0,0 +1,26 @@ +use crate::domain::tools::{Tool, ToolResult}; +use serde_yaml::Value as Yaml; + +pub struct Finish; + +impl Tool for Finish { + fn name(&self) -> &'static str { + "finish" + } + + fn work(&self, input: Yaml) -> ToolResult { + ToolResult::ok( + self.name(), + input, + Yaml::String("The request is fulfilled".to_string()), + ) + } + + fn desc(&self) -> &'static str { + "Finish working on user request, as everything asked for has been done." + } + + fn input_format(&self) -> &'static str { + "" + } +} diff --git a/src/domain/tools/list_objects.rs b/src/domain/tools/list_objects.rs index 7d7c0ba..4d6ea4f 100644 --- a/src/domain/tools/list_objects.rs +++ b/src/domain/tools/list_objects.rs @@ -1,27 +1,204 @@ -use crate::domain::tools::Tool; +use crate::utils::{Lang, ParsedObject, UniversalParser}; +use crate::domain::tools::{Tool, ToolResult}; +use serde::Deserialize; +use serde_yaml::Value as Yaml; +use std::collections::BTreeMap; -pub struct FindObjects; +pub struct ListObjects; -impl Tool for FindObjects { - fn name(&self) -> &'static str { - "find_objects" +impl ListObjects { + pub fn parse_file(file_path: &str) -> Result<(Lang, Vec), String> { + let mut parser = UniversalParser::new(); + parser.parse_file(file_path) } - fn work(&self, _input: &str) -> &'static str { - "" + fn parse_source(source: &str, lang: Lang) -> Result, String> { + let mut parser = UniversalParser::new(); + parser.parse(source, lang) + } + + fn format_output(lang: Lang, objects: &[ParsedObject]) -> String { + let mut output = String::new(); + output.push_str(&format!("language: {}\n", lang.name())); + output.push_str("objects:\n"); + + let mut by_kind: BTreeMap<&str, Vec<&ParsedObject>> = BTreeMap::new(); + for obj in objects { + by_kind.entry(obj.kind.name()).or_default().push(obj); + } + + for (kind, objs) in by_kind { + output.push_str(&format!(" {}:\n", kind)); + for obj in objs { + output.push_str(&format!( + " - name: \"{}\"\n lines: {}-{}\n", + obj.name, obj.line_start, obj.line_end + )); + if let Some(ref vis) = obj.visibility { + output.push_str(&format!(" visibility: {}\n", vis)); + } + } + } + + output + } +} + +#[derive(Deserialize)] +struct ListObjectsInput { + full_path_to_file: String, +} + +impl Tool for ListObjects { + fn name(&self) -> &'static str { + "list_objects" + } + + fn work(&self, input: Yaml) -> ToolResult { + let input_copy = input.clone(); + + let parsed: ListObjectsInput = match serde_yaml::from_value(input) { + Ok(p) => p, + Err(e) => return ToolResult::error(self.name(), input_copy, format!("invalid input: {}", e)), + }; + + if parsed.full_path_to_file.is_empty() { + return ToolResult::error(self.name(), input_copy, "full_path_to_file is required".to_string()); + } + + match Self::parse_file(&parsed.full_path_to_file) { + Ok((lang, objects)) => ToolResult::ok(self.name(), input_copy, Yaml::String(Self::format_output(lang, &objects))), + Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()), + } } fn desc(&self) -> &'static str { - "Lists objects by query (stub)" + "Lists language-aware objects in a source file. Supports: Rust, Python, JavaScript, TypeScript, Go, Java, C, C++, Ruby, and more." } - fn format(&self) -> &'static str { + fn input_format(&self) -> &'static str { " input: - file_name: string # full path to the file - query: string # what to search for -output: - results: array[string] + full_path_to_file: string " } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_rust_source() { + let source = r#" +pub struct MyStruct { + field: i32, +} + +enum MyEnum { + Variant1, + Variant2, +} + +pub trait MyTrait { + fn method(&self); +} + +impl MyTrait for MyStruct { + fn method(&self) {} +} + +impl MyStruct { + pub fn new() -> Self { + Self { field: 0 } + } +} + +pub const MAX_SIZE: usize = 100; + +fn private_function() {} + +pub fn public_function(x: i32) -> i32 { + x * 2 +} +"#; + let objects = ListObjects::parse_source(source, Lang::Rust).unwrap(); + + assert!(objects.iter().any(|o| o.name == "MyStruct")); + assert!(objects.iter().any(|o| o.name == "MyEnum")); + assert!(objects.iter().any(|o| o.name == "MyTrait")); + assert!(objects.iter().any(|o| o.name == "MAX_SIZE")); + assert!(objects.iter().any(|o| o.name == "private_function")); + assert!(objects.iter().any(|o| o.name == "public_function")); + } + + #[test] + fn test_parse_python_source() { + let source = r#" +def hello(): + pass + +class MyClass: + def __init__(self): + pass + + def method(self): + pass + +async def async_func(): + pass +"#; + let objects = ListObjects::parse_source(source, Lang::Python).unwrap(); + + assert!(objects.iter().any(|o| o.name == "hello")); + assert!(objects.iter().any(|o| o.name == "MyClass")); + assert!(objects.iter().any(|o| o.name == "__init__")); + assert!(objects.iter().any(|o| o.name == "method")); + assert!(objects.iter().any(|o| o.name == "async_func")); + } + + #[test] + fn test_parse_javascript_source() { + let source = r#" +function hello() { + console.log("Hello"); +} + +class MyClass { + constructor() {} + + method() {} +} + +const arrowFn = () => {}; +"#; + let objects = ListObjects::parse_source(source, Lang::JavaScript).unwrap(); + + assert!(objects.iter().any(|o| o.name == "hello")); + assert!(objects.iter().any(|o| o.name == "MyClass")); + } + + #[test] + fn test_parse_go_source() { + let source = r#" +package main + +func hello() { + fmt.Println("Hello") +} + +type Person struct { + Name string + Age int +} + +func (p *Person) Greet() string { + return "Hello, " + p.Name +} +"#; + let objects = ListObjects::parse_source(source, Lang::Go).unwrap(); + + assert!(objects.iter().any(|o| o.name == "hello")); + assert!(objects.iter().any(|o| o.name == "Greet")); + } +} diff --git a/src/domain/tools/mod.rs b/src/domain/tools/mod.rs new file mode 100644 index 0000000..fa1fbbb --- /dev/null +++ b/src/domain/tools/mod.rs @@ -0,0 +1,87 @@ +pub mod change; +mod find_files; +mod list_objects; +mod read_objects; +mod structure; +mod finish; + +pub use find_files::FindFiles; +pub use list_objects::ListObjects; +pub use read_objects::ReadObjects; +pub use structure::Structure; +pub use finish::Finish; + +use serde_yaml::Value as Yaml; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("invalid yaml: {0}")] + InvalidYaml(String), + + #[error("parse error: {0}")] + Parse(String), + + #[error("io error: {0}")] + Io(String), +} + +pub struct ToolResult { + tool_name: String, + input: Yaml, + is_successful: bool, + success_output: Yaml, + error_message: String, +} + +impl ToolResult { + pub fn ok(tool_name: impl Into, input: Yaml, output: Yaml) -> Self { + Self { + tool_name: tool_name.into(), + input, + is_successful: true, + success_output: output, + error_message: "".to_string(), + } + } + pub fn error(tool_name: impl Into, input: Yaml, message: impl Into) -> Self { + Self { + tool_name: tool_name.into(), + input, + is_successful: false, + success_output: Yaml::Null, + error_message: message.into(), + } + } + + pub fn output_string(&self) -> String { + if self.is_successful { + serde_yaml::to_string(&self.success_output) + .unwrap_or_else(|_| format!("{:?}", self.success_output)) + } else { + format!("Error: {}", self.error_message) + } + } + + pub fn input_string(&self) -> String { + serde_yaml::to_string(&self.input) + .unwrap_or_else(|_| format!("{:?}", self.input)) + } + + /// Generate a summary string for this tool result + /// Format: "Tool `tool_name` was executed successfully" or "Tool `tool_name` failed: error_message" + pub fn summary(&self) -> String { + if self.is_successful { + format!("Tool `{}` was executed successfully", self.tool_name) + } else { + format!("Tool `{}` failed: {}", self.tool_name, self.error_message) + } + } +} + +pub trait Tool { + fn name(&self) -> &'static str; + fn work(&self, input: Yaml) -> ToolResult; + fn desc(&self) -> &'static str; + fn input_format(&self) -> &'static str; +} diff --git a/src/domain/tools/read_objects.rs b/src/domain/tools/read_objects.rs index c802a35..38e5fc0 100644 --- a/src/domain/tools/read_objects.rs +++ b/src/domain/tools/read_objects.rs @@ -1,29 +1,301 @@ -use crate::domain::tools::Tool; +use super::{Error, Tool, ToolResult}; +use crate::utils::{Lang, ObjectKind, ParsedObject, UniversalParser}; +use serde::{Deserialize, Serialize}; +use serde_yaml::Value as Yaml; +use std::collections::HashMap; +use std::fs; -pub struct ListObjects; +pub struct ReadObjects; -impl Tool for ListObjects { - fn name(&self) -> &'static str { - "list_objects" +#[derive(Debug, Serialize)] +pub struct ObjectContent { + pub kind: String, + pub line_start: usize, + pub line_end: usize, + pub content: String, +} + +#[derive(Debug, Clone)] +pub struct ObjectQuery { + pub kind: Option, + pub name: String, +} + +impl ObjectQuery { + pub fn new(name: &str) -> Self { + Self { + kind: None, + name: name.to_string(), + } } - fn work(&self, _input: &str) -> &'static str { - "" + pub fn with_kind(kind: ObjectKind, name: &str) -> Self { + Self { + kind: Some(kind), + name: name.to_string(), + } + } + + fn matches(&self, obj: &ParsedObject) -> bool { + let name_matches = obj.name == self.name || obj.name.contains(&self.name); + + match self.kind { + Some(kind) => kind == obj.kind && name_matches, + None => name_matches, + } + } +} + +#[derive(Deserialize)] +struct ReadObjectsInput { + full_path_to_file: String, + queries: Vec, +} + +#[derive(Deserialize)] +struct QueryInput { + #[serde(default)] + kind: Option, + name: String, +} + +#[derive(Serialize)] +struct ReadObjectsOutput { + language: String, + results: HashMap, +} + +#[derive(Serialize)] +struct ErrorOutput { + error: String, +} + +impl ReadObjects { + pub fn read_objects( + file_path: &str, + queries: &[ObjectQuery], + ) -> Result<(Lang, HashMap), Error> { + let source_code = + fs::read_to_string(file_path).map_err(|e| Error::Io(format!("failed to read file: {}", e)))?; + + let mut parser = UniversalParser::new(); + let (lang, objects) = parser.parse_file(file_path) + .map_err(Error::Parse)?; + + let lines: Vec<&str> = source_code.lines().collect(); + let mut results = HashMap::new(); + + for query in queries { + for obj in &objects { + if query.matches(obj) { + let content = Self::extract_content(obj, &lines); + results.insert(obj.name.clone(), content); + } + } + } + + Ok((lang, results)) + } + + fn extract_content(obj: &ParsedObject, lines: &[&str]) -> ObjectContent { + let start_idx = obj.line_start.saturating_sub(1); + let end_idx = obj.line_end.min(lines.len()); + + let content = lines[start_idx..end_idx].join("\n"); + + ObjectContent { + kind: obj.kind.name().to_string(), + line_start: obj.line_start, + line_end: obj.line_end, + content, + } + } + + fn parse_input(input: Yaml) -> Result { + let parsed: ReadObjectsInput = + serde_yaml::from_value(input).map_err(|e| Error::InvalidYaml(e.to_string()))?; + + if parsed.queries.is_empty() { + return Err(Error::Parse("at least one query is required".into())); + } + + Ok(parsed) + } + + fn format_output(lang: Lang, results: HashMap) -> String { + let output = ReadObjectsOutput { + language: lang.name().to_string(), + results, + }; + serde_yaml::to_string(&output).unwrap_or_else(|e| format!("error: {}", e)) + } + + fn format_error(error: impl Into) -> String { + serde_yaml::to_string(&ErrorOutput { + error: error.into(), + }) + .unwrap_or_else(|e| format!("error: {}", e)) + } +} + +impl Tool for ReadObjects { + fn name(&self) -> &'static str { + "read_objects" + } + + fn work(&self, input: Yaml) -> ToolResult { + let input_copy = input.clone(); + + match Self::parse_input(input) { + Ok(parsed) => { + let queries: Vec = parsed + .queries + .into_iter() + .map(|q| match q.kind { + Some(kind_str) => ObjectQuery { + kind: ObjectKind::from_str(&kind_str), + name: q.name, + }, + None => ObjectQuery::new(&q.name), + }) + .collect(); + + match Self::read_objects(&parsed.full_path_to_file, &queries) { + Ok((lang, results)) => ToolResult::ok(self.name(), input_copy, Yaml::String(Self::format_output(lang, results))), + Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()), + } + } + Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()), + } } fn desc(&self) -> &'static str { - "Lists language-aware-objects in a file (like functions, classes, etc.)" + "Shows code for requested objects in a source file. Supports: Rust, Python, JavaScript, TypeScript, Go, Java, C, C++, Ruby, and more." } - fn format(&self) -> &'static str { + fn input_format(&self) -> &'static str { " input: - file_name: string # full path to the file -output: - results: - classes: [string] - functions: [string] - constants: [string] + full_path_to_file: string + queries: + - name: string + kind: string " } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_read_rust_objects() { + let source = r#" +pub struct MyStruct { + field: i32, +} + +impl MyStruct { + pub fn new() -> Self { + Self { field: 0 } + } + + pub fn get_field(&self) -> i32 { + self.field + } +} + +pub fn standalone_fn() -> i32 { + 42 +} +"#; + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("test.rs"); + std::fs::write(&file_path, source).unwrap(); + + let queries = vec![ + ObjectQuery::with_kind(ObjectKind::Struct, "MyStruct"), + ObjectQuery::with_kind(ObjectKind::Function, "new"), + ObjectQuery::with_kind(ObjectKind::Function, "standalone_fn"), + ]; + + let (lang, results) = + ReadObjects::read_objects(file_path.to_str().unwrap(), &queries).unwrap(); + + assert_eq!(lang, Lang::Rust); + assert_eq!(results.len(), 3); + + let struct_result = results.get("MyStruct").unwrap(); + assert_eq!(struct_result.kind, "struct"); + assert!(struct_result.content.contains("pub struct MyStruct")); + + let new_result = results.get("new").unwrap(); + assert_eq!(new_result.kind, "function"); + } + + #[test] + fn test_read_python_objects() { + let source = r#" +def hello(): + print("Hello") + +class MyClass: + def method(self): + pass +"#; + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("test.py"); + std::fs::write(&file_path, source).unwrap(); + + let queries = vec![ + ObjectQuery::new("hello"), + ObjectQuery::with_kind(ObjectKind::Class, "MyClass"), + ]; + + let (lang, results) = + ReadObjects::read_objects(file_path.to_str().unwrap(), &queries).unwrap(); + + assert_eq!(lang, Lang::Python); + assert!(results.contains_key("hello")); + assert!(results.contains_key("MyClass")); + } + + #[test] + fn test_parse_input() { + let input = r#" +full_path_to_file: /path/to/file.rs +queries: + - name: main + kind: function + - name: Config + kind: struct + - name: Parser +"#; + let yaml: Yaml = serde_yaml::from_str(input).unwrap(); + let parsed = ReadObjects::parse_input(yaml).unwrap(); + + assert_eq!(parsed.full_path_to_file, "/path/to/file.rs"); + assert_eq!(parsed.queries.len(), 3); + assert_eq!(parsed.queries[0].kind, Some("function".to_string())); + assert_eq!(parsed.queries[1].kind, Some("struct".to_string())); + assert_eq!(parsed.queries[2].kind, None); + } + + #[test] + fn test_query_matching() { + let obj = ParsedObject { + name: "my_function".to_string(), + kind: ObjectKind::Function, + line_start: 1, + line_end: 5, + byte_start: 0, + byte_end: 100, + visibility: None, + }; + + assert!(ObjectQuery::new("my_function").matches(&obj)); + assert!(ObjectQuery::with_kind(ObjectKind::Function, "my_function").matches(&obj)); + assert!(!ObjectQuery::with_kind(ObjectKind::Class, "my_function").matches(&obj)); + assert!(ObjectQuery::new("function").matches(&obj)); + } +} diff --git a/src/domain/tools/structure.rs b/src/domain/tools/structure.rs new file mode 100644 index 0000000..b89cf1d --- /dev/null +++ b/src/domain/tools/structure.rs @@ -0,0 +1,26 @@ +use crate::domain::tools::{Tool, ToolResult}; +use serde_yaml::Value as Yaml; + +pub struct Structure; + +impl Tool for Structure { + fn name(&self) -> &'static str { + "structure" + } + + fn work(&self, input: Yaml) -> ToolResult { + ToolResult::ok(self.name(), input, Yaml::String(String::new())) + } + + fn desc(&self) -> &'static str { + "Return the directory structure up to a given depth" + } + + fn input_format(&self) -> &'static str { + " +input: + path: string + max_depth: integer +" + } +} diff --git a/src/domain/workflow/chain.rs b/src/domain/workflow/chain.rs new file mode 100644 index 0000000..4a64e87 --- /dev/null +++ b/src/domain/workflow/chain.rs @@ -0,0 +1,243 @@ +use super::{step::ChainStep, Error}; +use crate::domain::tools::ToolResult; +use serde::{Deserialize, Serialize}; +use serde_yaml::Value as Yaml; + +use super::step::StepType; + +/// Represents an execution chain containing multiple steps +/// The chain is built incrementally as tools are executed +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Chain { + pub steps: Vec, + pub is_failed: bool, + pub fail_reason: String, +} + +impl Chain { + pub fn new() -> Self { + Self { + steps: Vec::new(), + is_failed: false, + fail_reason: String::new(), + } + } + + /// Add a step to the chain after executing a tool + pub fn add_step(&mut self, result: ToolResult) { + self.steps.push(ChainStep::new( + StepType::ToolCall, + Some(result), + )); + } + + /// Mark the chain as failed with a reason + pub fn mark_failed(&mut self, reason: String) { + self.is_failed = true; + self.fail_reason = reason; + } + + /// Add a user interruption step + pub fn add_interruption(&mut self) { + self.steps.push(ChainStep::new( + StepType::UserInterruption, + None, + )); + self.mark_failed("User interrupted".to_string()); + } + + pub fn steps(&self) -> &[ChainStep] { + &self.steps + } + + pub fn len(&self) -> usize { + self.steps.len() + } + + pub fn is_empty(&self) -> bool { + self.steps.is_empty() + } + + /// Get the finish message from the finish tool output + /// Returns the output of the finish tool if found, otherwise returns a default message + pub fn get_finish_message(&self) -> String { + // Look for the finish tool step by checking if summary contains "finish" + if let Some(finish_step) = self.steps.iter().find(|step| + step.step_type == StepType::ToolCall.as_str() && + step.summary.contains("finish") + ) { + // Extract message from summary like "Tool `finish` was executed successfully" + // For finish tool, we want to return a success message + if finish_step.summary.contains("successfully") { + return "The request is fulfilled".to_string(); + } + finish_step.summary.clone() + } else { + // No finish tool found, return a default message + if self.is_empty() { + "No steps executed.".to_string() + } else { + format!("Workflow completed. Executed {} steps.", self.len()) + } + } + } + + /// Get a summary of the chain execution + /// Returns a string describing how many tool calls were executed and whether it was successful + /// This should be saved to session_request.result_summary + pub fn get_summary(&self) -> String { + let tool_call_count = self.steps.iter() + .filter(|s| s.step_type == StepType::ToolCall.as_str()) + .count(); + + if self.is_failed { + format!("Executed {} tool calls. Failed: {}", tool_call_count, self.fail_reason) + } else { + format!("Executed {} tool calls. Success.", tool_call_count) + } + } + + /// Get the log of all steps + /// Returns text consisting of chain_step.summary for each step + /// This should be saved to session_request.steps_log + pub fn get_log(&self) -> String { + self.steps.iter() + .map(|step| step.summary.clone()) + .collect::>() + .join("\n") + } +} + +/// Parse a single tool choice from LLM output +/// Expected format (possibly wrapped in ```yaml ... ```): +/// ```yaml +/// tool_name: read_objects +/// input: +/// full_path_to_file: cat.py +/// queries: +/// - name: foo +/// kind: method +/// ``` +pub fn parse_tool_choice(llm_output: &str) -> Result<(String, Yaml), Error> { + // Strip markdown code block markers if present + let content = llm_output + .trim() + .strip_prefix("```yaml") + .or_else(|| llm_output.trim().strip_prefix("```")) + .unwrap_or(llm_output.trim()); + + let content = content + .trim() + .strip_suffix("```") + .unwrap_or(content) + .trim(); + + // Parse the YAML + let parsed: Yaml = serde_yaml::from_str(content) + .map_err(|e| Error::Parse(format!("invalid yaml: {}", e)))?; + + // Extract tool_name + let tool_name = parsed + .get("tool_name") + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::Parse("no tool_name found in LLM output".into()))?; + + if tool_name.is_empty() { + return Err(Error::Parse("empty tool_name".into())); + } + + // Extract input field (default to empty mapping if not present) + let input = parsed + .get("input") + .cloned() + .unwrap_or(Yaml::Mapping(serde_yaml::Mapping::new())); + + Ok((tool_name.to_string(), input)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_tool_choice() { + let output = r#"```yaml +tool_name: read_objects +input: + full_path_to_file: cat.py + queries: + - name: foo + kind: method +```"#; + let (tool_name, input) = parse_tool_choice(output).unwrap(); + assert_eq!(tool_name, "read_objects"); + assert_eq!(input["full_path_to_file"].as_str(), Some("cat.py")); + assert!(input["queries"].is_sequence()); + } + + #[test] + fn test_parse_tool_choice_no_markers() { + let output = r#"tool_name: change_replace +input: + full_path_to_file: main.rs + start_line: 10 + end_line: 15 + content: | + fn new_code() { + println!("hello"); + }"#; + let (tool_name, input) = parse_tool_choice(output).unwrap(); + assert_eq!(tool_name, "change_replace"); + assert_eq!(input["start_line"].as_u64(), Some(10)); + assert_eq!(input["end_line"].as_u64(), Some(15)); + assert!(input["content"].as_str().is_some()); + } + + #[test] + fn test_chain_add_step() { + use crate::domain::tools::ToolResult; + use crate::domain::workflow::step::StepType; + use serde_yaml::Value as Yaml; + + let mut chain = Chain::new(); + let result = ToolResult::ok("read_file", Yaml::String("file: test.rs".to_string()), Yaml::String("content here".to_string())); + chain.add_step(result); + + assert_eq!(chain.len(), 1); + assert_eq!(chain.steps[0].step_type, StepType::ToolCall.as_str()); + assert!(chain.steps[0].summary.contains("read_file")); + assert!(chain.steps[0].summary.contains("successfully")); + } + + #[test] + fn test_chain_get_summary() { + use crate::domain::tools::ToolResult; + use serde_yaml::Value as Yaml; + + let mut chain = Chain::new(); + let result = ToolResult::ok("read_file", Yaml::Null, Yaml::Null); + chain.add_step(result); + + let summary = chain.get_summary(); + assert!(summary.contains("Executed 1 tool calls")); + assert!(summary.contains("Success")); + assert!(!chain.is_failed); + } + + #[test] + fn test_chain_get_log() { + use crate::domain::tools::ToolResult; + use serde_yaml::Value as Yaml; + + let mut chain = Chain::new(); + let result1 = ToolResult::ok("read_file", Yaml::Null, Yaml::Null); + chain.add_step(result1); + let result2 = ToolResult::ok("find_files", Yaml::Null, Yaml::Null); + chain.add_step(result2); + + let log = chain.get_log(); + assert!(log.contains("read_file")); + assert!(log.contains("find_files")); + assert_eq!(log.lines().count(), 2); + } +} diff --git a/src/domain/workflow/mod.rs b/src/domain/workflow/mod.rs index e69de29..ab6f216 100644 --- a/src/domain/workflow/mod.rs +++ b/src/domain/workflow/mod.rs @@ -0,0 +1,73 @@ +pub mod workflow; +pub mod chain; +pub mod step; +pub mod toolset; + +pub use chain::Chain; +pub use step::{ChainStep, StepType}; +pub use toolset::Toolset; +pub use workflow::Workflow; + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use thiserror::Error; + +/// A token that can be used to signal cancellation to running operations. +/// Clone is cheap - it just clones the Arc. +#[derive(Clone)] +pub struct CancellationToken { + cancelled: Arc, +} + +impl CancellationToken { + pub fn new() -> Self { + Self { + cancelled: Arc::new(AtomicBool::new(false)), + } + } + + /// Check if cancellation has been requested + pub fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::Relaxed) + } + + /// Request cancellation + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Relaxed); + } + + /// Reset the token for reuse + pub fn reset(&self) { + self.cancelled.store(false, Ordering::Relaxed); + } +} + +impl Default for CancellationToken { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Error)] +pub enum Error { + #[error("no prompt set in session")] + NoPromptSet, + + #[error("inference failed: {0}")] + Inference(String), + + #[error("parse error: {0}")] + Parse(String), + + #[error("tool not found: {0}")] + ToolNotFound(String), + + #[error("cancelled")] + Cancelled, +} + +impl From for String { + fn from(err: Error) -> Self { + err.to_string() + } +} diff --git a/src/domain/workflow/plan.rs b/src/domain/workflow/plan.rs deleted file mode 100644 index e69de29..0000000 diff --git a/src/domain/workflow/step.rs b/src/domain/workflow/step.rs index e69de29..c54650e 100644 --- a/src/domain/workflow/step.rs +++ b/src/domain/workflow/step.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; +use crate::domain::tools::ToolResult; + +/// Types of steps that can occur in an execution chain +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum StepType { + /// A tool was called and executed + ToolCall, + /// User interrupted the workflow (Ctrl+C) + UserInterruption, + /// A todo item was created + TodoCreation, + /// A todo item was updated + TodoUpdate, +} + +impl StepType { + pub fn as_str(&self) -> &'static str { + match self { + StepType::ToolCall => "tool_call", + StepType::UserInterruption => "user_interruption", + StepType::TodoCreation => "todo_creation", + StepType::TodoUpdate => "todo_update", + } + } +} + +/// Represents a single step in an execution chain +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChainStep { + pub step_type: String, + pub summary: String, + pub context_payload: String, + pub input_payload: String, +} + +impl ChainStep { + pub fn new(step_type: StepType, tool_result: Option) -> Self { + let mut summary = String::new(); + if step_type == StepType::UserInterruption { + summary = "User interrupted".to_string(); + } + let mut context_payload = String::new(); + let mut input_payload = String::new(); + if let Some(tr) = tool_result { + summary = tr.summary(); + context_payload = tr.output_string(); + input_payload = tr.input_string(); + } + + Self { + step_type: step_type.as_str().to_string(), + summary, + context_payload, + input_payload, + } + } +} diff --git a/src/domain/workflow/toolset.rs b/src/domain/workflow/toolset.rs index e69de29..3aabe9a 100644 --- a/src/domain/workflow/toolset.rs +++ b/src/domain/workflow/toolset.rs @@ -0,0 +1,92 @@ +use super::Error; +use crate::domain::prompting::format_tools_description; +use crate::domain::tools::{Tool, ToolResult, FindFiles, ListObjects, ReadObjects, Structure}; +use serde_yaml::Value as Yaml; +use std::collections::HashMap; + +/// Manages the available tools and provides descriptions for LLM planning +pub struct Toolset { + tools: HashMap>, +} + +impl Toolset { + pub fn new() -> Self { + let mut tools: HashMap> = HashMap::new(); + + // Register all available tools + let list_objects = Box::new(ListObjects); + tools.insert(list_objects.name().to_string(), list_objects); + + let read_objects = Box::new(ReadObjects); + tools.insert(read_objects.name().to_string(), read_objects); + + let find_files = Box::new(FindFiles); + tools.insert(find_files.name().to_string(), find_files); + + let structure = Box::new(Structure); + tools.insert(structure.name().to_string(), structure); + + let finish = Box::new(crate::domain::tools::Finish); + tools.insert(finish.name().to_string(), finish); + + let insert = Box::new(crate::domain::tools::change::Insert); + tools.insert(insert.name().to_string(), insert); + + let remove = Box::new(crate::domain::tools::change::Remove); + tools.insert(remove.name().to_string(), remove); + + let replace = Box::new(crate::domain::tools::change::Replace); + tools.insert(replace.name().to_string(), replace); + + Self { tools } + } + + /// Get a tool by name + pub fn get_tool(&self, name: &str) -> Option<&dyn Tool> { + self.tools.get(name).map(|t| t.as_ref()) + } + + /// Generate a formatted description of all available tools for LLM planning + pub fn get_tools_description(&self) -> String { + format_tools_description(self.tools.iter().map(|(name, tool)| (name.as_str(), tool.as_ref()))) + } + + /// Execute a tool with the given input + pub fn execute_tool(&self, tool_name: &str, input: Yaml) -> Result { + match self.get_tool(tool_name) { + Some(tool) => { + let result = tool.work(input); + Ok(result) + } + None => Err(Error::ToolNotFound(tool_name.into())) + } + } +} + +impl Default for Toolset { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_tool() { + let toolset = Toolset::new(); + assert!(toolset.get_tool("read_objects").is_some()); + assert!(toolset.get_tool("find_files").is_some()); + assert!(toolset.get_tool("nonexistent").is_none()); + } + + #[test] + fn test_tools_description() { + let toolset = Toolset::new(); + let description = toolset.get_tools_description(); + assert!(description.contains("Available Tools:")); + assert!(description.contains("read_objects")); + assert!(description.contains("finish")); + } +} diff --git a/src/domain/workflow/workflow.rs b/src/domain/workflow/workflow.rs index e69de29..56b71eb 100644 --- a/src/domain/workflow/workflow.rs +++ b/src/domain/workflow/workflow.rs @@ -0,0 +1,128 @@ +use super::{chain::Chain, toolset::Toolset, CancellationToken, Error}; +use crate::domain::prompting; +use crate::domain::session::Session; +use crate::domain::tools::ToolResult; +use crate::infrastructure::inference::InferenceEngine; + +/// Main workflow orchestrator that runs LLM-driven coding tasks +/// Implements an eternal agent loop that: +/// 1. Asks LLM to choose the next tool +/// 2. Executes that tool +/// 3. Stores the result in the chain +/// 4. Repeats until "finish" tool is chosen or 128 iterations reached +pub struct Workflow { + toolset: Toolset, +} + +impl Workflow { + /// Create a new workflow with default toolset + pub fn new() -> Self { + Self { + toolset: Toolset::new(), + } + } + + /// Create a workflow with a custom toolset + pub fn with_toolset(toolset: Toolset) -> Self { + Self { toolset } + } + + /// Run the workflow for a given session + /// Implements the eternal agent loop: + /// - Asks LLM to choose next tool + /// - Executes the tool + /// - Stores result in chain + /// - Repeats until "finish" or 128 iterations + /// + /// The workflow checks the cancellation token before each iteration + /// and returns Error::Cancelled if cancellation was requested. + pub fn run( + &self, + session: &Session, + engine: &InferenceEngine, + cancel: &CancellationToken, + ) -> Result { + const MAX_ITERATIONS: usize = 128; + + // Get the user prompt from the session + let user_prompt = prompting::format_session_prompt(&session); + + // Initialize the chain to track executed steps + let mut chain = Chain::new(); + + // Eternal agent loop + for iteration in 1..=MAX_ITERATIONS { + // Check for cancellation at the start of each iteration + if cancel.is_cancelled() { + log::info!("Workflow cancelled by user"); + chain.add_interruption(); + return Ok(chain); + } + + // Create prompt with chain context + let prompt = prompting::tool_selection_prompt(&user_prompt, &self.toolset, &chain); + + // Ask LLM to choose next tool + let llm_output = engine.generate_silent(&prompt, 1024) + .map_err(Error::Inference)?; + + // Check for cancellation after inference + if cancel.is_cancelled() { + log::info!("Workflow cancelled by user"); + chain.add_interruption(); + return Ok(chain); + } + + // Parse tool choice from LLM output + let (tool_name, input_yaml) = super::chain::parse_tool_choice(&llm_output)?; + + // Check if we should finish + if tool_name == "finish" { + log::info!("Finish tool chosen, ending workflow"); + break; + } + + // Execute the tool + let tool_result = match self.toolset.execute_tool(&tool_name, input_yaml.clone()) { + Ok(result) => { + log::info!("Tool {} executed successfully", tool_name); + result + } + Err(error) => { + log::warn!("Tool {} failed: {}", tool_name, error); + let error_msg = format!("Tool execution failed: {}", error); + // Create an error ToolResult for consistency + let result = ToolResult::error( + tool_name.clone(), + input_yaml.clone(), + error_msg.clone(), + ); + chain.add_step(result); + chain.mark_failed(error_msg); + return Ok(chain); + } + }; + + // Add step to chain + chain.add_step(tool_result); + } + + if chain.len() >= MAX_ITERATIONS { + log::warn!("Workflow reached maximum iterations ({})", MAX_ITERATIONS); + chain.mark_failed("Maximum iterations reached".to_string()); + } + + Ok(chain) + } + + /// Get the toolset (for testing or inspection) + pub fn toolset(&self) -> &Toolset { + &self.toolset + } +} + +impl Default for Workflow { + fn default() -> Self { + Self::new() + } +} diff --git a/src/infrastructure/db.rs b/src/infrastructure/db.rs new file mode 100644 index 0000000..de4711c --- /dev/null +++ b/src/infrastructure/db.rs @@ -0,0 +1,128 @@ +use directories::ProjectDirs; +use rusqlite::Connection; +use std::fs; +use std::path::PathBuf; + +/// Resolves the OS-specific data directory for the application. +fn get_data_dir(app_name: &str) -> Result { + ProjectDirs::from("", "", app_name) + .map(|dirs| dirs.data_dir().to_path_buf()) + .ok_or_else(|| "Failed to resolve application data directory".to_string()) +} + +/// Initializes the SQLite database, creating it if necessary. +/// Returns an open Connection or exits with an error. +pub fn init_db(app_name: &str, _debug: bool) -> Result { + let data_dir = get_data_dir(app_name)?; + + // Ensure parent directories exist + fs::create_dir_all(&data_dir).map_err(|e| format!("Failed to create data directory: {}", e))?; + + let db_path = data_dir.join("agent.db"); + let db_exists = db_path.exists(); + + // Open or create database + let conn = + Connection::open(&db_path).map_err(|e| format!("Failed to open database: {}", e))?; + + if db_exists { + log::info!("Database opened: {}", db_path.display()); + } else { + log::info!("Database created: {}", db_path.display()); + } + + // Set pragmas + conn.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA busy_timeout = 5000;", + ) + .map_err(|e| format!("Failed to set pragmas: {}", e))?; + + // Create tables + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + session_count INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) + ); + + CREATE TABLE IF NOT EXISTS session_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + prompt TEXT NOT NULL, + result_summary TEXT, + steps_log TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) + );", + ) + .map_err(|e| format!("Failed to create tables: {}", e))?; + + // Get current schema version + let current_version: i32 = conn + .query_row( + "SELECT value FROM meta WHERE key = 'schema_version'", + [], + |row| row.get::<_, String>(0), + ) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1); // Default to version 1 if not found + + // Run migrations if needed + if current_version < 2 { + log::info!("Migrating database from version {} to 2", current_version); + + // Migration to version 2: add steps_log column + // Check if column already exists by querying table info + let column_exists = conn + .prepare("SELECT name FROM pragma_table_info('session_requests') WHERE name = 'steps_log'") + .and_then(|mut stmt| { + stmt.query_map([], |row| row.get::<_, String>(0)) + .map(|mut rows| rows.next().is_some()) + }) + .unwrap_or(false); + + if !column_exists { + log::info!("Adding steps_log column to session_requests table"); + conn.execute( + "ALTER TABLE session_requests ADD COLUMN steps_log TEXT", + [], + ) + .map_err(|e| format!("Failed to add steps_log column: {}", e))?; + } + + // Update schema version + conn.execute( + "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', '2')", + [], + ) + .map_err(|e| format!("Failed to update schema_version: {}", e))?; + + log::info!("Database migration completed"); + } else { + // Insert schema_version if missing (for new databases) + conn.execute( + "INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '2')", + [], + ) + .map_err(|e| format!("Failed to insert schema_version: {}", e))?; + } + + Ok(conn) +} diff --git a/src/infrastructure/inference.rs b/src/infrastructure/inference.rs new file mode 100644 index 0000000..236ec49 --- /dev/null +++ b/src/infrastructure/inference.rs @@ -0,0 +1,219 @@ +use llama_cpp_2::context::params::LlamaContextParams; +use llama_cpp_2::llama_backend::LlamaBackend; +use llama_cpp_2::llama_batch::LlamaBatch; +use llama_cpp_2::model::params::LlamaModelParams; +use llama_cpp_2::model::{AddBos, LlamaModel, Special}; +use llama_cpp_2::sampling::LlamaSampler; +use llama_cpp_2::{send_logs_to_tracing, LogOptions}; +use std::io::{self, Write}; +use std::num::NonZeroU32; +use std::path::Path; + +pub struct InferenceParams { + pub ctx_size: u32, + pub temperature: f32, + pub top_p: f32, + pub max_tokens: u32, + pub threads: i32, +} + +impl Default for InferenceParams { + fn default() -> Self { + let cpu_count = num_cpus::get() as i32; + Self { + ctx_size: 4096, + temperature: 0.7, + top_p: 0.9, + max_tokens: 512, + threads: (cpu_count - 1).max(1), + } + } +} + +pub struct InferenceEngine { + model: LlamaModel, + backend: LlamaBackend, + params: InferenceParams, +} + +impl InferenceEngine { + pub fn load>( + model_path: P, + params: InferenceParams, + debug: bool, + ) -> Result { + // Configure logging - suppress unless debug mode + let log_options = LogOptions::default().with_logs_enabled(debug); + send_logs_to_tracing(log_options); + + let backend = + LlamaBackend::init().map_err(|e| format!("Failed to init backend: {}", e))?; + + let model_params = LlamaModelParams::default(); + + let model = LlamaModel::load_from_file(&backend, model_path, &model_params) + .map_err(|e| format!("Failed to load model: {}", e))?; + + Ok(Self { + model, + backend, + params, + }) + } + + pub fn generate(&self, prompt: &str) -> Result { + self.generate_internal(prompt, true) + } + + /// Generate without printing to stdout (for internal use like session naming) + pub fn generate_silent(&self, prompt: &str, max_tokens: u32) -> Result { + let ctx_params = LlamaContextParams::default() + .with_n_ctx(NonZeroU32::new(self.params.ctx_size)) + .with_n_threads(self.params.threads) + .with_n_threads_batch(self.params.threads); + + let mut ctx = self + .model + .new_context(&self.backend, ctx_params) + .map_err(|e| format!("Failed to create context: {}", e))?; + + let tokens = self + .model + .str_to_token(prompt, AddBos::Always) + .map_err(|e| format!("Failed to tokenize: {}", e))?; + + if tokens.is_empty() { + return Err("Empty prompt".to_string()); + } + + let mut batch = LlamaBatch::new(self.params.ctx_size as usize, 1); + + for (i, token) in tokens.iter().enumerate() { + let is_last = i == tokens.len() - 1; + batch + .add(*token, i as i32, &[0], is_last) + .map_err(|_| "Failed to add token to batch")?; + } + + ctx.decode(&mut batch) + .map_err(|e| format!("Initial decode failed: {}", e))?; + + let mut sampler = LlamaSampler::chain_simple([ + LlamaSampler::temp(self.params.temperature), + LlamaSampler::top_p(self.params.top_p, 1), + LlamaSampler::dist(0), + ]); + + let mut output = String::new(); + let mut n_cur = tokens.len(); + + for _ in 0..max_tokens { + let new_token = sampler.sample(&ctx, -1); + + if self.model.is_eog_token(new_token) { + break; + } + + let token_str = self + .model + .token_to_str(new_token, Special::Tokenize) + .map_err(|e| format!("Token decode error: {}", e))?; + + output.push_str(&token_str); + + batch.clear(); + batch + .add(new_token, n_cur as i32, &[0], true) + .map_err(|_| "Failed to add token")?; + + ctx.decode(&mut batch) + .map_err(|e| format!("Decode failed: {}", e))?; + + n_cur += 1; + } + + Ok(output.trim().to_string()) + } + + fn generate_internal(&self, prompt: &str, stream: bool) -> Result { + let ctx_params = LlamaContextParams::default() + .with_n_ctx(NonZeroU32::new(self.params.ctx_size)) + .with_n_threads(self.params.threads) + .with_n_threads_batch(self.params.threads); + + let mut ctx = self + .model + .new_context(&self.backend, ctx_params) + .map_err(|e| format!("Failed to create context: {}", e))?; + + let tokens = self + .model + .str_to_token(prompt, AddBos::Always) + .map_err(|e| format!("Failed to tokenize: {}", e))?; + + if tokens.is_empty() { + return Err("Empty prompt".to_string()); + } + + let mut batch = LlamaBatch::new(self.params.ctx_size as usize, 1); + + for (i, token) in tokens.iter().enumerate() { + let is_last = i == tokens.len() - 1; + batch + .add(*token, i as i32, &[0], is_last) + .map_err(|_| "Failed to add token to batch")?; + } + + ctx.decode(&mut batch) + .map_err(|e| format!("Initial decode failed: {}", e))?; + + let mut sampler = LlamaSampler::chain_simple([ + LlamaSampler::temp(self.params.temperature), + LlamaSampler::top_p(self.params.top_p, 1), + LlamaSampler::dist(0), + ]); + + let mut output = String::new(); + let mut n_cur = tokens.len(); + + if stream { + print!("\n"); + io::stdout().flush().ok(); + } + + for _ in 0..self.params.max_tokens { + let new_token = sampler.sample(&ctx, -1); + + if self.model.is_eog_token(new_token) { + break; + } + + let token_str = self + .model + .token_to_str(new_token, Special::Tokenize) + .map_err(|e| format!("Token decode error: {}", e))?; + + if stream { + print!("{}", token_str); + io::stdout().flush().ok(); + } + output.push_str(&token_str); + + batch.clear(); + batch + .add(new_token, n_cur as i32, &[0], true) + .map_err(|_| "Failed to add token")?; + + ctx.decode(&mut batch) + .map_err(|e| format!("Decode failed: {}", e))?; + + n_cur += 1; + } + + if stream { + println!("\n"); + } + + Ok(output) + } +} diff --git a/src/infrastructure/init.rs b/src/infrastructure/init.rs new file mode 100644 index 0000000..1eeb8f7 --- /dev/null +++ b/src/infrastructure/init.rs @@ -0,0 +1,96 @@ +use crate::infrastructure::db; +use crate::infrastructure::inference::{InferenceEngine, InferenceParams}; +use crate::infrastructure::model_registry; +use rusqlite::Connection; + +/// Configuration for infrastructure initialization +#[derive(Debug, Clone)] +pub struct InfrastructureConfig { + pub debug: bool, + pub app_name: String, +} + +impl Default for InfrastructureConfig { + fn default() -> Self { + Self { + debug: false, + app_name: "drastis".to_string(), + } + } +} + +/// Result of infrastructure initialization +pub struct InfrastructureComponents { + pub connection: Connection, + pub engine: InferenceEngine, +} + +/// Infrastructure initialization service +/// Handles database setup, model loading, and inference engine initialization +pub struct InfrastructureInitializer { + config: InfrastructureConfig, +} + +impl InfrastructureInitializer { + /// Create a new infrastructure initializer + pub fn new(config: InfrastructureConfig) -> Self { + Self { config } + } + + /// Create with debug flag + pub fn with_debug(debug: bool) -> Self { + Self::new(InfrastructureConfig { + debug, + ..Default::default() + }) + } + + /// Initialize all infrastructure components + /// 1. Database connection + /// 2. Model scanning and selection + /// 3. Inference engine loading + pub fn initialize(&self) -> Result { + // 1. Initialize database + log::info!("Initializing database..."); + let connection = db::init_db(&self.config.app_name, self.config.debug)?; + + // 2. Scan and select model + log::info!("Scanning for GGUF models..."); + + let models = model_registry::scan_models().map_err(|e| e.to_string())?; + let selected = model_registry::select_model(models).map_err(|e| e.to_string())?; + + // 3. Load inference engine + log::info!("Loading model..."); + + let params = InferenceParams::default(); + log::debug!( + "Parameters: ctx={}, temp={}, top_p={}, max_tokens={}, threads={}", + params.ctx_size, params.temperature, params.top_p, params.max_tokens, params.threads + ); + + let engine = InferenceEngine::load(&selected, params, self.config.debug)?; + + log::info!("Infrastructure initialized successfully."); + + Ok(InfrastructureComponents { + connection, + engine, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_infrastructure_config() { + let config = InfrastructureConfig::default(); + assert!(!config.debug); + assert_eq!(config.app_name, "drastis"); + + let initializer = InfrastructureInitializer::with_debug(true); + assert!(initializer.config.debug); + } +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs new file mode 100644 index 0000000..f15d75f --- /dev/null +++ b/src/infrastructure/mod.rs @@ -0,0 +1,9 @@ +pub mod db; +pub mod inference; +pub mod init; +pub mod model_registry; + +pub use db::*; +pub use inference::*; +pub use init::*; +pub use model_registry::*; diff --git a/src/infrastructure/model_registry.rs b/src/infrastructure/model_registry.rs new file mode 100644 index 0000000..c74ea06 --- /dev/null +++ b/src/infrastructure/model_registry.rs @@ -0,0 +1,75 @@ +use std::fs; +use std::io::{self, Write}; +use std::path::PathBuf; + +const MODELS_DIR: &str = "./models"; + +pub fn scan_models() -> io::Result> { + let models_path = PathBuf::from(MODELS_DIR); + + if !models_path.exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("Models directory '{}' not found", MODELS_DIR), + )); + } + + let mut gguf_files: Vec = fs::read_dir(&models_path)? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .map(|ext| ext.to_string_lossy().to_lowercase() == "gguf") + .unwrap_or(false) + }) + .collect(); + + gguf_files.sort(); + Ok(gguf_files) +} + +pub fn select_model(models: Vec) -> io::Result { + match models.len() { + 0 => Err(io::Error::new( + io::ErrorKind::NotFound, + "No GGUF models found in ./models/", + )), + 1 => { + let model = &models[0]; + println!( + "Auto-selected: {}", + model.file_name().unwrap_or_default().to_string_lossy() + ); + Ok(model.clone()) + } + _ => prompt_user_selection(&models), + } +} + +fn prompt_user_selection(models: &[PathBuf]) -> io::Result { + println!("Available models:"); + for (i, model) in models.iter().enumerate() { + println!( + " [{}] {}", + i + 1, + model.file_name().unwrap_or_default().to_string_lossy() + ); + } + + loop { + print!("Select model (1-{}): ", models.len()); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + match input.trim().parse::() { + Ok(n) if n >= 1 && n <= models.len() => { + return Ok(models[n - 1].clone()); + } + _ => { + println!("Invalid selection. Please enter a number between 1 and {}.", models.len()); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8665cc5 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +pub mod utils; +pub mod domain; +pub mod infrastructure; +pub mod repository; + +pub use domain::*; +pub use infrastructure::*; +pub use repository::*; +pub use utils::*; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f566eff --- /dev/null +++ b/src/main.rs @@ -0,0 +1,167 @@ +mod utils; +mod domain; +mod infrastructure; +mod repository; + +use clap::Parser; +use domain::{StartupService, SessionService, Session, CancellationToken}; +use infrastructure::InfrastructureInitializer; +use rustyline::DefaultEditor; +use rustyline::KeyEvent; +use rustyline::Cmd; +use utils::{handle_readline_error, Action}; +use std::env; + +#[derive(Parser)] +#[command(name = "drastis")] +#[command(about = "Local LLM inference CLI", long_about = None)] +struct Args { + /// Enable debug output from llama.cpp + #[arg(short, long)] + debug: bool, +} + +fn main() { + let args = Args::parse(); + + if let Err(e) = run(args.debug) { + eprintln!("Error: {}", e); + std::process::exit(1); + } +} + +fn run(debug: bool) -> Result<(), String> { + // Initialize logging + { + let mut builder = env_logger::Builder::from_default_env(); + + // Our crate logs: respect debug flag + if debug { + builder.filter_level(log::LevelFilter::Debug); + } else { + builder.filter_level(log::LevelFilter::Info); + } + + // Always silence very chatty deps like rustyline below DEBUG + builder.filter_module("rustyline", log::LevelFilter::Info); + + builder.init(); + } + + // Initialize infrastructure components + let infrastructure_initializer = InfrastructureInitializer::with_debug(debug); + let infrastructure = infrastructure_initializer.initialize()?; + + println!("Application initialized. Type :q or press Ctrl+Q to exit.\n"); + + // Start the REPL with infrastructure components + repl(infrastructure.connection, infrastructure.engine, debug) +} + + +fn repl( + connection: rusqlite::Connection, + engine: infrastructure::InferenceEngine, + debug: bool, +) -> Result<(), String> { + let mut rl = DefaultEditor::new().map_err(|e| e.to_string())?; + + // Bind Ctrl+Q to EOF (quit) + rl.bind_sequence(KeyEvent::ctrl('q'), Cmd::EndOfFile); + + let session_service = SessionService::new(); + let startup_service = StartupService::with_debug(debug); + let mut current_session: Option = None; + + // Set up cancellation token for Ctrl+C handling + let cancel_token = CancellationToken::new(); + let cancel_token_handler = cancel_token.clone(); + + ctrlc::set_handler(move || { + cancel_token_handler.cancel(); + }).map_err(|e| format!("Failed to set Ctrl+C handler: {}", e))?; + + // Load history if exists + let history_path = dirs_home().join(".drastis_history"); + let _ = rl.load_history(&history_path); + + loop { + // Reset cancellation token before each prompt + cancel_token.reset(); + + match rl.readline("You> ") { + Ok(line) => { + let trimmed = line.trim(); + + if trimmed.is_empty() { + continue; + } + + // Handle :q and :quit commands + if trimmed == ":q" || trimmed == ":quit" { + println!("Goodbye!"); + break; + } + + let _ = rl.add_history_entry(&line); + + // Handle session creation or restart + if current_session.is_none() { + // Create new session (without requests - session_service will create them) + current_session = Some(startup_service.start(&connection, &engine, trimmed)?); + } + + let session = current_session.as_ref().unwrap(); + + // Run the session service (creates request, runs workflow, updates result) + match session_service.run(session, trimmed, &engine, &cancel_token, &connection) { + Ok(chain) => { + let finish_message = chain.get_finish_message(); + println!("Ok> {}", finish_message); + } + Err(domain::session::ServiceError::Workflow(domain::workflow::Error::Cancelled)) => { + println!("\nInterrupted."); + } + Err(e) => { + eprintln!("Error: {}", e); + } + } + + // Reload session to get updated requests + current_session = Some(startup_service.load_session(&connection, session.id())?); + } + Err(err) => { + // Handle keyboard shortcuts (Ctrl+C, Ctrl+Q, Ctrl+D) + match handle_readline_error(err)? { + Action::Cancel => { + // Ctrl+C - cancel current operation + println!("^C"); + continue; + } + Action::Quit => { + // Ctrl+Q or Ctrl+D - quit the application + println!("Goodbye!"); + break; + } + Action::Continue => { + // Should not happen, but continue if it does + continue; + } + } + } + } + } + + // Save history + let _ = rl.save_history(&history_path); + + Ok(()) +} + + +fn dirs_home() -> std::path::PathBuf { + env::var("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from(".")) +} + diff --git a/src/repository/meta.rs b/src/repository/meta.rs new file mode 100644 index 0000000..78deb25 --- /dev/null +++ b/src/repository/meta.rs @@ -0,0 +1,56 @@ +use rusqlite::{Connection, params}; + +pub struct MetaRepository<'a> { + conn: &'a Connection, +} + +impl<'a> MetaRepository<'a> { + pub fn new(conn: &'a Connection) -> Self { + Self { conn } + } + + pub fn get(&self, key: &str) -> Result, String> { + let mut stmt = self + .conn + .prepare("SELECT value FROM meta WHERE key = ?") + .map_err(|e| e.to_string())?; + + let result = stmt + .query_row(params![key], |row| row.get(0)) + .optional() + .map_err(|e| e.to_string())?; + + Ok(result) + } + + pub fn set(&self, key: &str, value: &str) -> Result<(), String> { + self.conn + .execute( + "INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", + params![key, value], + ) + .map_err(|e| e.to_string())?; + Ok(()) + } + + pub fn get_schema_version(&self) -> Result { + self.get("schema_version")? + .ok_or_else(|| "schema_version not found".to_string())? + .parse() + .map_err(|e: std::num::ParseIntError| e.to_string()) + } +} + +trait OptionalExt { + fn optional(self) -> Result, rusqlite::Error>; +} + +impl OptionalExt for Result { + fn optional(self) -> Result, rusqlite::Error> { + match self { + Ok(v) => Ok(Some(v)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + } +} diff --git a/src/repository/mod.rs b/src/repository/mod.rs new file mode 100644 index 0000000..4e532ca --- /dev/null +++ b/src/repository/mod.rs @@ -0,0 +1,9 @@ +pub mod meta; +pub mod projects; +pub mod sessions; +pub mod session_requests; + +pub use meta::MetaRepository; +pub use projects::{ProjectRow, ProjectsRepository}; +pub use sessions::{SessionRow, SessionsRepository}; +pub use session_requests::{SessionRequestRow, SessionRequestsRepository}; diff --git a/src/repository/projects.rs b/src/repository/projects.rs new file mode 100644 index 0000000..d05a95a --- /dev/null +++ b/src/repository/projects.rs @@ -0,0 +1,108 @@ +use rusqlite::{Connection, params, Row}; + +/// Raw database row for projects table +#[derive(Debug, Clone)] +pub struct ProjectRow { + pub id: i64, + pub name: String, + pub created_at: String, + pub session_count: i64, +} + +impl ProjectRow { + fn from_row(row: &Row) -> Result { + Ok(Self { + id: row.get(0)?, + name: row.get(1)?, + created_at: row.get(2)?, + session_count: row.get(3)?, + }) + } +} + +pub struct ProjectsRepository<'a> { + conn: &'a Connection, +} + +impl<'a> ProjectsRepository<'a> { + pub fn new(conn: &'a Connection) -> Self { + Self { conn } + } + + pub fn find_by_name(&self, name: &str) -> Result, String> { + let mut stmt = self + .conn + .prepare("SELECT id, name, created_at, session_count FROM projects WHERE name = ?") + .map_err(|e| e.to_string())?; + + let result = stmt + .query_row(params![name], ProjectRow::from_row) + .optional() + .map_err(|e| e.to_string())?; + + Ok(result) + } + + pub fn create(&self, name: &str) -> Result { + let created_at = chrono_now(); + + self.conn + .execute( + "INSERT INTO projects (name, created_at, session_count) VALUES (?, ?, 0)", + params![name, created_at], + ) + .map_err(|e| e.to_string())?; + + let id = self.conn.last_insert_rowid(); + + Ok(ProjectRow { + id, + name: name.to_string(), + created_at, + session_count: 0, + }) + } + + pub fn increment_session_count(&self, project_id: i64) -> Result<(), String> { + self.conn + .execute( + "UPDATE projects SET session_count = session_count + 1 WHERE id = ?", + params![project_id], + ) + .map_err(|e| e.to_string())?; + Ok(()) + } + + pub fn get_or_create(&self, name: &str) -> Result<(ProjectRow, bool), String> { + if let Some(row) = self.find_by_name(name)? { + Ok((row, false)) + } else { + let row = self.create(name)?; + Ok((row, true)) + } + } +} + +fn chrono_now() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + // ISO 8601 format approximation + let secs = duration.as_secs(); + format!("{}", secs) +} + +trait OptionalExt { + fn optional(self) -> Result, rusqlite::Error>; +} + +impl OptionalExt for Result { + fn optional(self) -> Result, rusqlite::Error> { + match self { + Ok(v) => Ok(Some(v)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + } +} diff --git a/src/repository/session_requests.rs b/src/repository/session_requests.rs new file mode 100644 index 0000000..f1db467 --- /dev/null +++ b/src/repository/session_requests.rs @@ -0,0 +1,240 @@ +use rusqlite::{Connection, params, Row}; + +/// Raw database row for session_requests table +#[derive(Debug, Clone)] +pub struct SessionRequestRow { + pub id: i64, + pub session_id: i64, + pub prompt: String, + pub result_summary: Option, + pub steps_log: Option, + pub created_at: String, +} + +impl SessionRequestRow { + fn from_row(row: &Row) -> Result { + Ok(Self { + id: row.get(0)?, + session_id: row.get(1)?, + prompt: row.get(2)?, + result_summary: row.get(3)?, + steps_log: row.get(4)?, + created_at: row.get(5)?, + }) + } +} + +pub struct SessionRequestsRepository<'a> { + conn: &'a Connection, +} + +impl<'a> SessionRequestsRepository<'a> { + pub fn new(conn: &'a Connection) -> Self { + Self { conn } + } + + /// Create a new session request + pub fn create(&self, session_id: i64, prompt: &str) -> Result { + let created_at = chrono_now(); + + self.conn + .execute( + "INSERT INTO session_requests (session_id, prompt, created_at) VALUES (?, ?, ?)", + params![session_id, prompt, created_at], + ) + .map_err(|e| e.to_string())?; + + let id = self.conn.last_insert_rowid(); + + Ok(SessionRequestRow { + id, + session_id, + prompt: prompt.to_string(), + result_summary: None, + steps_log: None, + created_at, + }) + } + + /// Update the result summary for a request + pub fn update_result(&self, request_id: i64, result_summary: &str) -> Result<(), String> { + self.conn + .execute( + "UPDATE session_requests SET result_summary = ? WHERE id = ?", + params![result_summary, request_id], + ) + .map_err(|e| e.to_string())?; + + Ok(()) + } + + /// Update the steps log for a request + pub fn update_steps_log(&self, request_id: i64, steps_log: &str) -> Result<(), String> { + self.conn + .execute( + "UPDATE session_requests SET steps_log = ? WHERE id = ?", + params![steps_log, request_id], + ) + .map_err(|e| e.to_string())?; + + Ok(()) + } + + /// Find a request by ID + pub fn find_by_id(&self, id: i64) -> Result, String> { + let mut stmt = self + .conn + .prepare("SELECT id, session_id, prompt, result_summary, steps_log, created_at FROM session_requests WHERE id = ?") + .map_err(|e| e.to_string())?; + + let result = stmt + .query_row(params![id], SessionRequestRow::from_row) + .optional() + .map_err(|e| e.to_string())?; + + Ok(result) + } + + /// Find all requests for a session, ordered by creation time + pub fn find_by_session(&self, session_id: i64) -> Result, String> { + let mut stmt = self + .conn + .prepare("SELECT id, session_id, prompt, result_summary, steps_log, created_at FROM session_requests WHERE session_id = ? ORDER BY created_at ASC") + .map_err(|e| e.to_string())?; + + let rows = stmt + .query_map(params![session_id], SessionRequestRow::from_row) + .map_err(|e| e.to_string())?; + + let mut requests = Vec::new(); + for row in rows { + requests.push(row.map_err(|e| e.to_string())?); + } + + Ok(requests) + } + + /// Get the latest request for a session (most recent) + pub fn find_latest_by_session(&self, session_id: i64) -> Result, String> { + let mut stmt = self + .conn + .prepare("SELECT id, session_id, prompt, result_summary, steps_log, created_at FROM session_requests WHERE session_id = ? ORDER BY created_at DESC LIMIT 1") + .map_err(|e| e.to_string())?; + + let result = stmt + .query_row(params![session_id], SessionRequestRow::from_row) + .optional() + .map_err(|e| e.to_string())?; + + Ok(result) + } + + /// Delete all requests for a session + pub fn delete_by_session(&self, session_id: i64) -> Result { + let affected = self + .conn + .execute("DELETE FROM session_requests WHERE session_id = ?", params![session_id]) + .map_err(|e| e.to_string())?; + + Ok(affected) + } +} + +// Helper function to get current timestamp as string +fn chrono_now() -> String { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + .to_string() +} + +// Extension trait for Option to handle database results +trait OptionalExt { + fn optional(self) -> Result, rusqlite::Error>; +} + +impl OptionalExt for Result { + fn optional(self) -> Result, rusqlite::Error> { + match self { + Ok(value) => Ok(Some(value)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn setup_test_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + + // Create the tables + conn.execute_batch( + "CREATE TABLE sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + name TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE session_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + prompt TEXT NOT NULL, + result_summary TEXT, + steps_log TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) + );" + ).unwrap(); + + // Insert a test session + conn.execute( + "INSERT INTO sessions (project_id, name, created_at) VALUES (1, 'Test Session', '1234567890')", + [] + ).unwrap(); + + conn + } + + #[test] + fn test_create_request() { + let conn = setup_test_db(); + let repo = SessionRequestsRepository::new(&conn); + + let request = repo.create(1, "Test prompt").unwrap(); + assert_eq!(request.session_id, 1); + assert_eq!(request.prompt, "Test prompt"); + assert!(request.result_summary.is_none()); + } + + #[test] + fn test_update_result() { + let conn = setup_test_db(); + let repo = SessionRequestsRepository::new(&conn); + + let request = repo.create(1, "Test prompt").unwrap(); + repo.update_result(request.id, "Test result").unwrap(); + + let updated = repo.find_by_id(request.id).unwrap().unwrap(); + assert_eq!(updated.result_summary, Some("Test result".to_string())); + } + + #[test] + fn test_find_by_session() { + let conn = setup_test_db(); + let repo = SessionRequestsRepository::new(&conn); + + repo.create(1, "First prompt").unwrap(); + repo.create(1, "Second prompt").unwrap(); + + let requests = repo.find_by_session(1).unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].prompt, "First prompt"); + assert_eq!(requests[1].prompt, "Second prompt"); + } +} diff --git a/src/repository/sessions.rs b/src/repository/sessions.rs new file mode 100644 index 0000000..9550a84 --- /dev/null +++ b/src/repository/sessions.rs @@ -0,0 +1,108 @@ +use rusqlite::{Connection, params, Row}; + +/// Raw database row for sessions table +#[derive(Debug, Clone)] +pub struct SessionRow { + pub id: i64, + pub project_id: i64, + pub name: String, + pub created_at: String, +} + +impl SessionRow { + fn from_row(row: &Row) -> Result { + Ok(Self { + id: row.get(0)?, + project_id: row.get(1)?, + name: row.get(2)?, + created_at: row.get(3)?, + }) + } +} + +pub struct SessionsRepository<'a> { + conn: &'a Connection, +} + +impl<'a> SessionsRepository<'a> { + pub fn new(conn: &'a Connection) -> Self { + Self { conn } + } + + pub fn create(&self, project_id: i64, name: &str) -> Result { + let created_at = chrono_now(); + + self.conn + .execute( + "INSERT INTO sessions (project_id, name, created_at) VALUES (?, ?, ?)", + params![project_id, name, created_at], + ) + .map_err(|e| e.to_string())?; + + let id = self.conn.last_insert_rowid(); + + Ok(SessionRow { + id, + project_id, + name: name.to_string(), + created_at, + }) + } + + pub fn find_by_id(&self, id: i64) -> Result, String> { + let mut stmt = self + .conn + .prepare("SELECT id, project_id, name, created_at FROM sessions WHERE id = ?") + .map_err(|e| e.to_string())?; + + let result = stmt + .query_row(params![id], SessionRow::from_row) + .optional() + .map_err(|e| e.to_string())?; + + Ok(result) + } + + pub fn find_by_project(&self, project_id: i64) -> Result, String> { + let mut stmt = self + .conn + .prepare( + "SELECT id, project_id, name, created_at FROM sessions WHERE project_id = ? ORDER BY created_at DESC", + ) + .map_err(|e| e.to_string())?; + + let rows = stmt + .query_map(params![project_id], SessionRow::from_row) + .map_err(|e| e.to_string())?; + + let mut sessions = Vec::new(); + for row in rows { + sessions.push(row.map_err(|e| e.to_string())?); + } + + Ok(sessions) + } +} + +fn chrono_now() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let secs = duration.as_secs(); + format!("{}", secs) +} + +trait OptionalExt { + fn optional(self) -> Result, rusqlite::Error>; +} + +impl OptionalExt for Result { + fn optional(self) -> Result, rusqlite::Error> { + match self { + Ok(v) => Ok(Some(v)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + } +} diff --git a/src/utils/io/file_operations.rs b/src/utils/io/file_operations.rs new file mode 100644 index 0000000..f275f9d --- /dev/null +++ b/src/utils/io/file_operations.rs @@ -0,0 +1,246 @@ +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone)] +pub enum FileInsertError { + FileNotFound(String), + InvalidLineNumber(String), + IoError(String), +} + +#[derive(Debug, Clone)] +pub enum FileRemoveError { + FileNotFound(String), + InvalidLineNumber(String), + InvalidCount(String), + IoError(String), +} + +#[derive(Debug, Clone)] +pub enum FileReplaceError { + FileNotFound(String), + InvalidLineNumber(String), + InvalidRange(String), + IoError(String), +} + +impl std::fmt::Display for FileInsertError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FileInsertError::FileNotFound(path) => write!(f, "File not found: {}", path), + FileInsertError::InvalidLineNumber(msg) => write!(f, "Invalid line number: {}", msg), + FileInsertError::IoError(msg) => write!(f, "IO error: {}", msg), + } + } +} + +impl std::fmt::Display for FileRemoveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FileRemoveError::FileNotFound(path) => write!(f, "File not found: {}", path), + FileRemoveError::InvalidLineNumber(msg) => write!(f, "Invalid line number: {}", msg), + FileRemoveError::InvalidCount(msg) => write!(f, "Invalid count: {}", msg), + FileRemoveError::IoError(msg) => write!(f, "IO error: {}", msg), + } + } +} + +impl std::fmt::Display for FileReplaceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FileReplaceError::FileNotFound(path) => write!(f, "File not found: {}", path), + FileReplaceError::InvalidLineNumber(msg) => write!(f, "Invalid line number: {}", msg), + FileReplaceError::InvalidRange(msg) => write!(f, "Invalid range: {}", msg), + FileReplaceError::IoError(msg) => write!(f, "IO error: {}", msg), + } + } +} + +/// Insert content at a specific line in a file. +/// Line numbers are 1-based. +pub fn insert_content(file_path: &str, target_line: usize, content: &str) -> Result<(), FileInsertError> { + if !Path::new(file_path).exists() { + return Err(FileInsertError::FileNotFound(file_path.to_string())); + } + + if target_line == 0 { + return Err(FileInsertError::InvalidLineNumber("Line numbers must be 1-based".to_string())); + } + + let file_content = fs::read_to_string(file_path) + .map_err(|e| FileInsertError::IoError(format!("Failed to read file: {}", e)))?; + + let mut lines: Vec = file_content.lines().map(|s| s.to_string()).collect(); + + // Convert 1-based line number to 0-based index + let insert_index = if target_line > lines.len() { + lines.len() + } else { + target_line - 1 + }; + + // Split the content to insert into lines + let content_lines: Vec = content.lines().map(|s| s.to_string()).collect(); + + // Insert the content + for (i, line) in content_lines.iter().enumerate() { + lines.insert(insert_index + i, line.clone()); + } + + let new_content = lines.join("\n"); + fs::write(file_path, new_content) + .map_err(|e| FileInsertError::IoError(format!("Failed to write file: {}", e)))?; + + Ok(()) +} + +/// Remove a specified number of lines starting from a target line. +/// Line numbers are 1-based. +pub fn remove_lines(file_path: &str, target_line: usize, count: usize) -> Result<(), FileRemoveError> { + if !Path::new(file_path).exists() { + return Err(FileRemoveError::FileNotFound(file_path.to_string())); + } + + if target_line == 0 { + return Err(FileRemoveError::InvalidLineNumber("Line numbers must be 1-based".to_string())); + } + + if count == 0 { + return Err(FileRemoveError::InvalidCount("Count must be greater than 0".to_string())); + } + + let file_content = fs::read_to_string(file_path) + .map_err(|e| FileRemoveError::IoError(format!("Failed to read file: {}", e)))?; + + let mut lines: Vec = file_content.lines().map(|s| s.to_string()).collect(); + + // Convert 1-based line number to 0-based index + let start_index = if target_line > lines.len() { + return Err(FileRemoveError::InvalidLineNumber( + format!("Line {} is beyond file length ({})", target_line, lines.len()) + )); + } else { + target_line - 1 + }; + + let end_index = (start_index + count).min(lines.len()); + + // Remove the lines + lines.drain(start_index..end_index); + + let new_content = lines.join("\n"); + fs::write(file_path, new_content) + .map_err(|e| FileRemoveError::IoError(format!("Failed to write file: {}", e)))?; + + Ok(()) +} + +/// Replace content between start_line and end_line (inclusive) with new content. +/// Line numbers are 1-based. +pub fn replace_lines(file_path: &str, start_line: usize, end_line: usize, content: &str) -> Result<(), FileReplaceError> { + if !Path::new(file_path).exists() { + return Err(FileReplaceError::FileNotFound(file_path.to_string())); + } + + if start_line == 0 || end_line == 0 { + return Err(FileReplaceError::InvalidLineNumber("Line numbers must be 1-based".to_string())); + } + + if start_line > end_line { + return Err(FileReplaceError::InvalidRange( + format!("Start line ({}) must be <= end line ({})", start_line, end_line) + )); + } + + let file_content = fs::read_to_string(file_path) + .map_err(|e| FileReplaceError::IoError(format!("Failed to read file: {}", e)))?; + + let mut lines: Vec = file_content.lines().map(|s| s.to_string()).collect(); + + // Convert 1-based line numbers to 0-based indices + let start_index = if start_line > lines.len() { + return Err(FileReplaceError::InvalidLineNumber( + format!("Start line {} is beyond file length ({})", start_line, lines.len()) + )); + } else { + start_line - 1 + }; + + let end_index = if end_line > lines.len() { + lines.len() + } else { + end_line + }; + + // Split the replacement content into lines + let replacement_lines: Vec = content.lines().map(|s| s.to_string()).collect(); + + // Remove the old lines and insert the new ones + lines.drain(start_index..end_index); + for (i, line) in replacement_lines.iter().enumerate() { + lines.insert(start_index + i, line.clone()); + } + + let new_content = lines.join("\n"); + fs::write(file_path, new_content) + .map_err(|e| FileReplaceError::IoError(format!("Failed to write file: {}", e)))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + use std::fs; + + fn create_test_file(dir: &TempDir, name: &str, content: &str) -> String { + let file_path = dir.path().join(name); + fs::write(&file_path, content).unwrap(); + file_path.to_str().unwrap().to_string() + } + + #[test] + fn test_insert_content() { + let dir = tempfile::tempdir().unwrap(); + let file_path = create_test_file(&dir, "test.txt", "line 1\nline 2\nline 3"); + + insert_content(&file_path, 2, "inserted line\nanother inserted line").unwrap(); + + let content = fs::read_to_string(&file_path).unwrap(); + assert_eq!(content, "line 1\ninserted line\nanother inserted line\nline 2\nline 3"); + } + + #[test] + fn test_remove_lines() { + let dir = tempfile::tempdir().unwrap(); + let file_path = create_test_file(&dir, "test.txt", "line 1\nline 2\nline 3\nline 4\nline 5"); + + remove_lines(&file_path, 2, 2).unwrap(); + + let content = fs::read_to_string(&file_path).unwrap(); + assert_eq!(content, "line 1\nline 4\nline 5"); + } + + #[test] + fn test_replace_lines() { + let dir = tempfile::tempdir().unwrap(); + let file_path = create_test_file(&dir, "test.txt", "line 1\nline 2\nline 3\nline 4"); + + replace_lines(&file_path, 2, 3, "new line 2\nnew line 3").unwrap(); + + let content = fs::read_to_string(&file_path).unwrap(); + assert_eq!(content, "line 1\nnew line 2\nnew line 3\nline 4"); + } + + #[test] + fn test_insert_at_end() { + let dir = tempfile::tempdir().unwrap(); + let file_path = create_test_file(&dir, "test.txt", "line 1\nline 2"); + + insert_content(&file_path, 10, "line 3").unwrap(); + + let content = fs::read_to_string(&file_path).unwrap(); + assert_eq!(content, "line 1\nline 2\nline 3"); + } +} \ No newline at end of file diff --git a/src/utils/io/mod.rs b/src/utils/io/mod.rs new file mode 100644 index 0000000..1b3135d --- /dev/null +++ b/src/utils/io/mod.rs @@ -0,0 +1,3 @@ +pub mod file_operations; + +pub use file_operations::{FileInsertError, FileRemoveError, FileReplaceError, insert_content, remove_lines, replace_lines}; \ No newline at end of file diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..1bb6eaf --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,7 @@ +pub mod io; +pub mod parsing; +pub mod shortcuts; + +pub use io::{FileInsertError, FileRemoveError, FileReplaceError, insert_content, remove_lines, replace_lines}; +pub use parsing::{HeuristicObject, HeuristicParser, Lang, ObjectKind, ParsedObject, ParseResult, TreeSitterParser, UniversalParser}; +pub use shortcuts::{handle_readline_error, Action}; \ No newline at end of file diff --git a/src/utils/parsing/heuristics.rs b/src/utils/parsing/heuristics.rs new file mode 100644 index 0000000..e0d30ab --- /dev/null +++ b/src/utils/parsing/heuristics.rs @@ -0,0 +1,559 @@ +use regex::Regex; +use std::path::Path; + +use super::tree_sitter::ObjectKind; + +#[derive(Debug, Clone)] +pub struct HeuristicObject { + pub name: String, + pub kind: ObjectKind, + pub line_start: usize, + pub line_end: usize, + pub indent_level: usize, +} + +#[derive(Debug, Clone)] +pub struct LanguagePatterns { + pub name: &'static str, + pub extensions: &'static [&'static str], + pub patterns: &'static [(&'static str, ObjectKind)], + pub block_start: Option<&'static str>, + pub block_end: Option<&'static str>, + pub indent_based: bool, +} + +pub struct HeuristicParser { + language_patterns: Vec, +} + +impl HeuristicParser { + pub fn new() -> Self { + Self { + language_patterns: Self::default_patterns(), + } + } + + fn default_patterns() -> Vec { + vec![ + // PHP + LanguagePatterns { + name: "PHP", + extensions: &["php", "phtml", "php3", "php4", "php5", "phps"], + patterns: &[ + (r"(?m)^\s*(?:public|private|protected|static|\s)*\s*function\s+(\w+)\s*\(", ObjectKind::Function), + (r"(?m)^\s*(?:abstract\s+)?class\s+(\w+)", ObjectKind::Class), + (r"(?m)^\s*interface\s+(\w+)", ObjectKind::Interface), + (r"(?m)^\s*trait\s+(\w+)", ObjectKind::Trait), + (r"(?m)^\s*(?:final\s+)?enum\s+(\w+)", ObjectKind::Enum), + (r"(?m)^\s*const\s+(\w+)\s*=", ObjectKind::Constant), + ], + block_start: Some("{"), + block_end: Some("}"), + indent_based: false, + }, + // Kotlin + LanguagePatterns { + name: "Kotlin", + extensions: &["kt", "kts"], + patterns: &[ + (r"(?m)^\s*(?:public|private|protected|internal|inline|suspend|\s)*\s*fun\s+(?:<[^>]+>\s*)?(\w+)\s*\(", ObjectKind::Function), + (r"(?m)^\s*(?:data\s+|sealed\s+|abstract\s+|open\s+)?class\s+(\w+)", ObjectKind::Class), + (r"(?m)^\s*(?:fun\s+)?interface\s+(\w+)", ObjectKind::Interface), + (r"(?m)^\s*object\s+(\w+)", ObjectKind::Module), + (r"(?m)^\s*enum\s+class\s+(\w+)", ObjectKind::Enum), + (r"(?m)^\s*(?:const\s+)?val\s+(\w+)\s*[=:]", ObjectKind::Constant), + ], + block_start: Some("{"), + block_end: Some("}"), + indent_based: false, + }, + // C# + LanguagePatterns { + name: "C#", + extensions: &["cs"], + patterns: &[ + (r"(?m)^\s*(?:public|private|protected|internal|static|async|virtual|override|abstract|\s)*\s*(?:\w+(?:<[^>]+>)?)\s+(\w+)\s*\([^)]*\)\s*(?:where\s+\w+\s*:\s*\w+\s*)?[{]?", ObjectKind::Function), + (r"(?m)^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*\s*class\s+(\w+)", ObjectKind::Class), + (r"(?m)^\s*(?:public|private|protected|internal|\s)*\s*interface\s+(\w+)", ObjectKind::Interface), + (r"(?m)^\s*(?:public|private|protected|internal|\s)*\s*struct\s+(\w+)", ObjectKind::Struct), + (r"(?m)^\s*(?:public|private|protected|internal|\s)*\s*enum\s+(\w+)", ObjectKind::Enum), + (r"(?m)^\s*namespace\s+([\w.]+)", ObjectKind::Module), + (r"(?m)^\s*(?:public|private|protected|internal|\s)*\s*const\s+\w+\s+(\w+)\s*=", ObjectKind::Constant), + ], + block_start: Some("{"), + block_end: Some("}"), + indent_based: false, + }, + // Swift + LanguagePatterns { + name: "Swift", + extensions: &["swift"], + patterns: &[ + (r"(?m)^\s*(?:@\w+\s+)*(?:public|private|internal|fileprivate|open|static|class|override|\s)*\s*func\s+(\w+)\s*[<(]", ObjectKind::Function), + (r"(?m)^\s*(?:public|private|internal|fileprivate|open|final|\s)*\s*class\s+(\w+)", ObjectKind::Class), + (r"(?m)^\s*(?:public|private|internal|fileprivate|\s)*\s*struct\s+(\w+)", ObjectKind::Struct), + (r"(?m)^\s*(?:public|private|internal|fileprivate|\s)*\s*enum\s+(\w+)", ObjectKind::Enum), + (r"(?m)^\s*(?:public|private|internal|fileprivate|\s)*\s*protocol\s+(\w+)", ObjectKind::Interface), + (r"(?m)^\s*(?:public|private|internal|fileprivate|\s)*\s*extension\s+(\w+)", ObjectKind::Class), + ], + block_start: Some("{"), + block_end: Some("}"), + indent_based: false, + }, + // Scala + LanguagePatterns { + name: "Scala", + extensions: &["scala", "sc"], + patterns: &[ + (r"(?m)^\s*(?:override\s+)?(?:private|protected|\s)*\s*def\s+(\w+)\s*[(\[]", ObjectKind::Function), + (r"(?m)^\s*(?:case\s+)?class\s+(\w+)", ObjectKind::Class), + (r"(?m)^\s*trait\s+(\w+)", ObjectKind::Trait), + (r"(?m)^\s*object\s+(\w+)", ObjectKind::Module), + (r"(?m)^\s*(?:sealed\s+)?enum\s+(\w+)", ObjectKind::Enum), + (r"(?m)^\s*val\s+(\w+)\s*[=:]", ObjectKind::Constant), + ], + block_start: Some("{"), + block_end: Some("}"), + indent_based: false, + }, + // Lua + LanguagePatterns { + name: "Lua", + extensions: &["lua"], + patterns: &[ + (r"(?m)^\s*(?:local\s+)?function\s+(\w+(?:\.\w+)*)\s*\(", ObjectKind::Function), + (r"(?m)^\s*(\w+)\s*=\s*function\s*\(", ObjectKind::Function), + (r"(?m)^\s*local\s+(\w+)\s*=\s*\{", ObjectKind::Module), + ], + block_start: None, + block_end: Some("end"), + indent_based: false, + }, + // Perl + LanguagePatterns { + name: "Perl", + extensions: &["pl", "pm", "t"], + patterns: &[ + (r"(?m)^\s*sub\s+(\w+)\s*[{(]?", ObjectKind::Function), + (r"(?m)^\s*package\s+([\w:]+)", ObjectKind::Module), + ], + block_start: Some("{"), + block_end: Some("}"), + indent_based: false, + }, + // R + LanguagePatterns { + name: "R", + extensions: &["r", "R", "rmd", "Rmd"], + patterns: &[ + (r"(?m)^\s*(\w+)\s*<-\s*function\s*\(", ObjectKind::Function), + (r"(?m)^\s*(\w+)\s*=\s*function\s*\(", ObjectKind::Function), + (r#"(?m)^\s*setClass\s*\(\s*["'](\w+)["']"#, ObjectKind::Class), + ], + block_start: Some("{"), + block_end: Some("}"), + indent_based: false, + }, + // Haskell + LanguagePatterns { + name: "Haskell", + extensions: &["hs", "lhs"], + patterns: &[ + (r"(?m)^(\w+)\s*::\s*", ObjectKind::Function), + (r"(?m)^\s*data\s+(\w+)", ObjectKind::Struct), + (r"(?m)^\s*newtype\s+(\w+)", ObjectKind::Struct), + (r"(?m)^\s*class\s+(\w+)", ObjectKind::Class), + (r"(?m)^\s*instance\s+(\w+)", ObjectKind::Class), + (r"(?m)^\s*module\s+([\w.]+)", ObjectKind::Module), + ], + block_start: None, + block_end: None, + indent_based: true, + }, + // Elixir + LanguagePatterns { + name: "Elixir", + extensions: &["ex", "exs"], + patterns: &[ + (r"(?m)^\s*def\s+(\w+)[(\s]", ObjectKind::Function), + (r"(?m)^\s*defp\s+(\w+)[(\s]", ObjectKind::Function), + (r"(?m)^\s*defmodule\s+([\w.]+)", ObjectKind::Module), + (r"(?m)^\s*defmacro\s+(\w+)", ObjectKind::Function), + (r"(?m)^\s*defstruct\s+", ObjectKind::Struct), + ], + block_start: Some("do"), + block_end: Some("end"), + indent_based: false, + }, + // Clojure + LanguagePatterns { + name: "Clojure", + extensions: &["clj", "cljs", "cljc", "edn"], + patterns: &[ + (r"(?m)^\s*\(defn-?\s+(\w+)", ObjectKind::Function), + (r"(?m)^\s*\(defmacro\s+(\w+)", ObjectKind::Function), + (r"(?m)^\s*\(defrecord\s+(\w+)", ObjectKind::Struct), + (r"(?m)^\s*\(defprotocol\s+(\w+)", ObjectKind::Interface), + (r"(?m)^\s*\(ns\s+([\w.-]+)", ObjectKind::Module), + ], + block_start: Some("("), + block_end: Some(")"), + indent_based: false, + }, + // OCaml + LanguagePatterns { + name: "OCaml", + extensions: &["ml", "mli"], + patterns: &[ + (r"(?m)^\s*let\s+(?:rec\s+)?(\w+)\s*[=:]", ObjectKind::Function), + (r"(?m)^\s*type\s+(\w+)", ObjectKind::Struct), + (r"(?m)^\s*module\s+(\w+)", ObjectKind::Module), + (r"(?m)^\s*class\s+(\w+)", ObjectKind::Class), + ], + block_start: None, + block_end: None, + indent_based: false, + }, + // Zig + LanguagePatterns { + name: "Zig", + extensions: &["zig"], + patterns: &[ + (r"(?m)^\s*(?:pub\s+)?fn\s+(\w+)\s*\(", ObjectKind::Function), + (r"(?m)^\s*(?:pub\s+)?const\s+(\w+)\s*=\s*struct", ObjectKind::Struct), + (r"(?m)^\s*(?:pub\s+)?const\s+(\w+)\s*=\s*enum", ObjectKind::Enum), + (r"(?m)^\s*(?:pub\s+)?const\s+(\w+)\s*=", ObjectKind::Constant), + ], + block_start: Some("{"), + block_end: Some("}"), + indent_based: false, + }, + // Dart + LanguagePatterns { + name: "Dart", + extensions: &["dart"], + patterns: &[ + (r"(?m)^\s*(?:static\s+)?(?:\w+(?:<[^>]+>)?)\s+(\w+)\s*\([^)]*\)\s*(?:async\s*)?[{]", ObjectKind::Function), + (r"(?m)^\s*(?:abstract\s+)?class\s+(\w+)", ObjectKind::Class), + (r"(?m)^\s*enum\s+(\w+)", ObjectKind::Enum), + (r"(?m)^\s*mixin\s+(\w+)", ObjectKind::Trait), + (r"(?m)^\s*extension\s+(\w+)", ObjectKind::Class), + ], + block_start: Some("{"), + block_end: Some("}"), + indent_based: false, + }, + // Generic fallback for unknown languages + LanguagePatterns { + name: "Unknown", + extensions: &[], + patterns: &[ + (r"(?m)^\s*(?:pub\s+|public\s+|private\s+|protected\s+)?(?:static\s+)?(?:async\s+)?(?:fn|func|function|def|sub|proc)\s+(\w+)\s*[(<]", ObjectKind::Function), + (r"(?m)^\s*(?:pub\s+|public\s+)?(?:abstract\s+)?(?:final\s+)?class\s+(\w+)", ObjectKind::Class), + (r"(?m)^\s*(?:pub\s+|public\s+)?struct\s+(\w+)", ObjectKind::Struct), + (r"(?m)^\s*(?:pub\s+|public\s+)?enum\s+(\w+)", ObjectKind::Enum), + (r"(?m)^\s*(?:pub\s+|public\s+)?(?:interface|protocol|trait)\s+(\w+)", ObjectKind::Interface), + (r"(?m)^\s*(?:pub\s+|public\s+)?module\s+(\w+)", ObjectKind::Module), + ], + block_start: Some("{"), + block_end: Some("}"), + indent_based: false, + }, + ] + } + + pub fn detect_language(&self, path: &str) -> Option<&LanguagePatterns> { + let ext = Path::new(path) + .extension() + .and_then(|e| e.to_str())?; + + self.language_patterns + .iter() + .find(|lang| lang.extensions.contains(&ext)) + } + + pub fn get_fallback(&self) -> &LanguagePatterns { + self.language_patterns.last().unwrap() + } + + pub fn parse(&self, source: &str, lang: &LanguagePatterns) -> Vec { + let mut objects = Vec::new(); + let lines: Vec<&str> = source.lines().collect(); + + for (pattern_str, kind) in lang.patterns { + if let Ok(regex) = Regex::new(pattern_str) { + for cap in regex.captures_iter(source) { + if let Some(name_match) = cap.get(1) { + let byte_start = name_match.start(); + let line_start = source[..byte_start].matches('\n').count() + 1; + + let line_end = self.find_block_end( + &lines, + line_start, + lang, + ); + + let indent_level = lines + .get(line_start.saturating_sub(1)) + .map(|l| l.len() - l.trim_start().len()) + .unwrap_or(0); + + objects.push(HeuristicObject { + name: name_match.as_str().to_string(), + kind: *kind, + line_start, + line_end, + indent_level, + }); + } + } + } + } + + objects.sort_by_key(|o| o.line_start); + objects.dedup_by(|a, b| a.line_start == b.line_start && a.name == b.name); + + objects + } + + fn find_block_end( + &self, + lines: &[&str], + start_line: usize, + lang: &LanguagePatterns, + ) -> usize { + let start_idx = start_line.saturating_sub(1); + + if start_idx >= lines.len() { + return start_line; + } + + if lang.indent_based { + return self.find_indent_block_end(lines, start_idx); + } + + let (block_start, block_end) = match (lang.block_start, lang.block_end) { + (Some(s), Some(e)) => (s, e), + (None, Some(e)) => ("", e), + _ => return self.find_indent_block_end(lines, start_idx), + }; + + let mut depth = 0; + let mut found_start = block_start.is_empty(); + + for (i, line) in lines.iter().enumerate().skip(start_idx) { + if !found_start && line.contains(block_start) { + found_start = true; + depth = 1; + continue; + } + + if found_start { + for ch in line.chars() { + if !block_start.is_empty() && block_start.starts_with(ch) { + depth += 1; + } else if block_end.starts_with(ch) { + depth -= 1; + if depth == 0 { + return i + 1; + } + } + } + + if block_end.len() > 1 && line.trim() == block_end { + depth -= 1; + if depth <= 0 { + return i + 1; + } + } + } + } + + lines.len() + } + + fn find_indent_block_end(&self, lines: &[&str], start_idx: usize) -> usize { + if start_idx >= lines.len() { + return start_idx + 1; + } + + let start_indent = lines[start_idx].len() - lines[start_idx].trim_start().len(); + + for (i, line) in lines.iter().enumerate().skip(start_idx + 1) { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let current_indent = line.len() - trimmed.len(); + if current_indent <= start_indent { + return i; + } + } + + lines.len() + } + + pub fn parse_file(&self, path: &str) -> Result<(String, Vec), String> { + let source = std::fs::read_to_string(path) + .map_err(|e| format!("Failed to read file: {}", e))?; + + let lang = self.detect_language(path).unwrap_or_else(|| self.get_fallback()); + let objects = self.parse(&source, lang); + + Ok((lang.name.to_string(), objects)) + } +} + +impl Default for HeuristicParser { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_php_parsing() { + let source = r#" + FetchData() { + return await Task.FromResult(42); + } + } + + public interface IService { + void DoWork(); + } + + public enum Status { + Active, + Inactive + } +} +"#; + let parser = HeuristicParser::new(); + let lang = parser.detect_language("test.cs").unwrap(); + let objects = parser.parse(source, lang); + + assert!(objects.iter().any(|o| o.name == "MyApp")); + assert!(objects.iter().any(|o| o.name == "MyClass")); + assert!(objects.iter().any(|o| o.name == "IService")); + assert!(objects.iter().any(|o| o.name == "Status")); + } + + #[test] + fn test_swift_parsing() { + let source = r#" +class MyClass { + func hello() -> String { + return "Hello" + } + + @objc private func callback() { + } +} + +struct Point { + var x: Int + var y: Int +} + +protocol Drawable { + func draw() +} +"#; + let parser = HeuristicParser::new(); + let lang = parser.detect_language("test.swift").unwrap(); + let objects = parser.parse(source, lang); + + assert!(objects.iter().any(|o| o.name == "MyClass")); + assert!(objects.iter().any(|o| o.name == "hello")); + assert!(objects.iter().any(|o| o.name == "Point")); + assert!(objects.iter().any(|o| o.name == "Drawable")); + } + + #[test] + fn test_fallback_for_unknown() { + let source = r#" +function hello() { + print("hello") +} + +class Foo { +} +"#; + let parser = HeuristicParser::new(); + let lang = parser.detect_language("test.xyz"); + assert!(lang.is_none()); + + let fallback = parser.get_fallback(); + let objects = parser.parse(source, fallback); + + assert!(objects.iter().any(|o| o.name == "hello")); + assert!(objects.iter().any(|o| o.name == "Foo")); + } +} diff --git a/src/utils/parsing/mod.rs b/src/utils/parsing/mod.rs new file mode 100644 index 0000000..d4cec6f --- /dev/null +++ b/src/utils/parsing/mod.rs @@ -0,0 +1,7 @@ +pub mod heuristics; +pub mod tree_sitter; +pub mod parser; + +pub use heuristics::{HeuristicObject, HeuristicParser}; +pub use tree_sitter::{Lang, ObjectKind, ParsedObject, TreeSitterParser}; +pub use parser::{ParseResult, UniversalParser}; \ No newline at end of file diff --git a/src/utils/parsing/parser.rs b/src/utils/parsing/parser.rs new file mode 100644 index 0000000..89d508c --- /dev/null +++ b/src/utils/parsing/parser.rs @@ -0,0 +1,205 @@ +use super::heuristics::{HeuristicParser, HeuristicObject}; +use super::tree_sitter::{TreeSitterParser, Lang, ParsedObject}; + +#[derive(Debug, Clone)] +pub struct ParseResult { + pub language: String, + pub objects: Vec, + pub used_heuristics: bool, +} + +impl ParsedObject { + pub fn from_heuristic(obj: &HeuristicObject) -> Self { + Self { + name: obj.name.clone(), + kind: obj.kind, + line_start: obj.line_start, + line_end: obj.line_end, + byte_start: 0, + byte_end: 0, + visibility: None, + } + } +} + +/// Universal parser that tries Tree-sitter first and falls back to heuristics if needed +pub struct UniversalParser { + tree_sitter_parser: TreeSitterParser, + heuristic_parser: HeuristicParser, +} + +impl UniversalParser { + pub fn new() -> Self { + Self { + tree_sitter_parser: TreeSitterParser::new(), + heuristic_parser: HeuristicParser::new(), + } + } + + /// Parse source code with a known language using Tree-sitter + pub fn parse(&mut self, source: &str, lang: Lang) -> Result, String> { + self.tree_sitter_parser.parse(source, lang) + } + + /// Parse a file with a known language using Tree-sitter + pub fn parse_file(&mut self, path: &str) -> Result<(Lang, Vec), String> { + self.tree_sitter_parser.parse_file(path) + } + + /// Parse a file, trying Tree-sitter first and falling back to heuristics if needed + pub fn parse_file_with_fallback(&mut self, path: &str) -> Result { + // Try Tree-sitter first if the language is supported + if TreeSitterParser::supports(path) { + match self.tree_sitter_parser.parse_file(path) { + Ok((lang, objects)) => { + return Ok(ParseResult { + language: lang.name().to_string(), + objects, + used_heuristics: false, + }); + } + Err(_) => { + // Tree-sitter failed, fall back to heuristics + } + } + } + + // Fall back to heuristics + let (lang_name, heuristic_objects) = self.heuristic_parser.parse_file(path)?; + let objects = heuristic_objects + .iter() + .map(ParsedObject::from_heuristic) + .collect(); + + Ok(ParseResult { + language: lang_name, + objects, + used_heuristics: true, + }) + } + + /// Parse source code with fallback, given a file path for language detection + pub fn parse_source_with_fallback( + &mut self, + source: &str, + path: &str, + ) -> Result { + // Try Tree-sitter first if the language is supported + if let Some(lang) = Lang::from_path(path) { + match self.tree_sitter_parser.parse(source, lang) { + Ok(objects) => { + return Ok(ParseResult { + language: lang.name().to_string(), + objects, + used_heuristics: false, + }); + } + Err(_) => { + // Tree-sitter failed, fall back to heuristics + } + } + } + + // Fall back to heuristics + let lang_patterns = self + .heuristic_parser + .detect_language(path) + .unwrap_or_else(|| self.heuristic_parser.get_fallback()); + + let heuristic_objects = self.heuristic_parser.parse(source, lang_patterns); + let objects = heuristic_objects + .iter() + .map(ParsedObject::from_heuristic) + .collect(); + + Ok(ParseResult { + language: lang_patterns.name.to_string(), + objects, + used_heuristics: true, + }) + } +} + +impl Default for UniversalParser { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_rust_with_tree_sitter() { + let source = r#" +pub fn hello() {} +struct Point { x: i32, y: i32 } +impl Point { + fn new() -> Self { Self { x: 0, y: 0 } } +} +"#; + let mut parser = UniversalParser::new(); + let result = parser + .parse_source_with_fallback(source, "test.rs") + .unwrap(); + + assert!(!result.used_heuristics); + assert_eq!(result.language, "Rust"); + assert!(result.objects.iter().any(|o| o.name == "hello")); + assert!(result.objects.iter().any(|o| o.name == "Point")); + assert!(result.objects.iter().any(|o| o.name == "new")); + } + + #[test] + fn test_fallback_to_heuristics_php() { + let source = r#" + Option { + match ext.to_lowercase().as_str() { + "rs" => Some(Lang::Rust), + "py" | "pyi" | "pyw" => Some(Lang::Python), + "js" | "mjs" | "cjs" => Some(Lang::JavaScript), + "ts" | "mts" | "cts" => Some(Lang::TypeScript), + "tsx" | "jsx" => Some(Lang::Tsx), + "go" => Some(Lang::Go), + "java" => Some(Lang::Java), + "c" | "h" => Some(Lang::C), + "cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => Some(Lang::Cpp), + "rb" | "rake" | "gemspec" => Some(Lang::Ruby), + "json" => Some(Lang::Json), + "toml" => Some(Lang::Toml), + "html" | "htm" => Some(Lang::Html), + "css" | "scss" | "sass" => Some(Lang::Css), + "sh" | "bash" | "zsh" => Some(Lang::Bash), + "md" | "markdown" => Some(Lang::Markdown), + _ => None, + } + } + + pub fn from_path(path: &str) -> Option { + Path::new(path) + .extension() + .and_then(|ext| ext.to_str()) + .and_then(Self::from_extension) + } + + pub fn name(&self) -> &'static str { + match self { + Lang::Rust => "Rust", + Lang::Python => "Python", + Lang::JavaScript => "JavaScript", + Lang::TypeScript => "TypeScript", + Lang::Tsx => "TSX", + Lang::Go => "Go", + Lang::Java => "Java", + Lang::C => "C", + Lang::Cpp => "C++", + Lang::Ruby => "Ruby", + Lang::Json => "JSON", + Lang::Toml => "TOML", + Lang::Html => "HTML", + Lang::Css => "CSS", + Lang::Bash => "Bash", + Lang::Markdown => "Markdown", + } + } + + pub fn tree_sitter_language(&self) -> Language { + match self { + Lang::Rust => tree_sitter_rust::LANGUAGE.into(), + Lang::Python => tree_sitter_python::LANGUAGE.into(), + Lang::JavaScript => tree_sitter_javascript::LANGUAGE.into(), + Lang::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + Lang::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(), + Lang::Go => tree_sitter_go::LANGUAGE.into(), + Lang::Java => tree_sitter_java::LANGUAGE.into(), + Lang::C => tree_sitter_c::LANGUAGE.into(), + Lang::Cpp => tree_sitter_cpp::LANGUAGE.into(), + Lang::Ruby => tree_sitter_ruby::LANGUAGE.into(), + Lang::Json => tree_sitter_json::LANGUAGE.into(), + Lang::Toml => tree_sitter_toml_ng::LANGUAGE.into(), + Lang::Html => tree_sitter_html::LANGUAGE.into(), + Lang::Css => tree_sitter_css::LANGUAGE.into(), + Lang::Bash => tree_sitter_bash::LANGUAGE.into(), + Lang::Markdown => tree_sitter_md::LANGUAGE.into(), + } + } + + pub fn object_node_types(&self) -> &[ObjectNodeMapping] { + match self { + Lang::Rust => &RUST_MAPPINGS, + Lang::Python => &PYTHON_MAPPINGS, + Lang::JavaScript | Lang::TypeScript | Lang::Tsx => &JS_TS_MAPPINGS, + Lang::Go => &GO_MAPPINGS, + Lang::Java => &JAVA_MAPPINGS, + Lang::C => &C_MAPPINGS, + Lang::Cpp => &CPP_MAPPINGS, + Lang::Ruby => &RUBY_MAPPINGS, + Lang::Json | Lang::Toml => &CONFIG_MAPPINGS, + Lang::Html => &HTML_MAPPINGS, + Lang::Css => &CSS_MAPPINGS, + Lang::Bash => &BASH_MAPPINGS, + Lang::Markdown => &MARKDOWN_MAPPINGS, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObjectKind { + Function, + Method, + Class, + Struct, + Enum, + Interface, + Trait, + Impl, + Module, + Constant, + Variable, + Type, + Macro, + Import, + Export, + Property, + Field, + Section, + Rule, +} + +impl ObjectKind { + pub fn name(&self) -> &'static str { + match self { + ObjectKind::Function => "function", + ObjectKind::Method => "method", + ObjectKind::Class => "class", + ObjectKind::Struct => "struct", + ObjectKind::Enum => "enum", + ObjectKind::Interface => "interface", + ObjectKind::Trait => "trait", + ObjectKind::Impl => "impl", + ObjectKind::Module => "module", + ObjectKind::Constant => "constant", + ObjectKind::Variable => "variable", + ObjectKind::Type => "type", + ObjectKind::Macro => "macro", + ObjectKind::Import => "import", + ObjectKind::Export => "export", + ObjectKind::Property => "property", + ObjectKind::Field => "field", + ObjectKind::Section => "section", + ObjectKind::Rule => "rule", + } + } + + /// Parse a string representation of an object kind + /// Returns None if the string doesn't match any known kind + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "function" | "fn" | "func" | "def" => Some(ObjectKind::Function), + "method" => Some(ObjectKind::Method), + "class" => Some(ObjectKind::Class), + "struct" => Some(ObjectKind::Struct), + "enum" => Some(ObjectKind::Enum), + "interface" => Some(ObjectKind::Interface), + "trait" => Some(ObjectKind::Trait), + "impl" => Some(ObjectKind::Impl), + "module" | "mod" => Some(ObjectKind::Module), + "const" | "constant" => Some(ObjectKind::Constant), + "var" | "variable" => Some(ObjectKind::Variable), + "type" => Some(ObjectKind::Type), + "macro" => Some(ObjectKind::Macro), + "import" => Some(ObjectKind::Import), + "export" => Some(ObjectKind::Export), + "property" => Some(ObjectKind::Property), + "field" => Some(ObjectKind::Field), + "section" => Some(ObjectKind::Section), + "rule" => Some(ObjectKind::Rule), + _ => None, + } + } +} + +#[derive(Debug, Clone)] +pub struct ObjectNodeMapping { + pub node_type: &'static str, + pub kind: ObjectKind, + pub name_field: Option<&'static str>, + pub name_child_type: Option<&'static str>, +} + +impl ObjectNodeMapping { + const fn new(node_type: &'static str, kind: ObjectKind) -> Self { + Self { + node_type, + kind, + name_field: Some("name"), + name_child_type: None, + } + } + + const fn with_name_child(mut self, child_type: &'static str) -> Self { + self.name_field = None; + self.name_child_type = Some(child_type); + self + } + + const fn no_name(mut self) -> Self { + self.name_field = None; + self.name_child_type = None; + self + } +} + +static RUST_MAPPINGS: &[ObjectNodeMapping] = &[ + ObjectNodeMapping::new("function_item", ObjectKind::Function), + ObjectNodeMapping::new("struct_item", ObjectKind::Struct), + ObjectNodeMapping::new("enum_item", ObjectKind::Enum), + ObjectNodeMapping::new("trait_item", ObjectKind::Trait), + ObjectNodeMapping::new("impl_item", ObjectKind::Impl).no_name(), + ObjectNodeMapping::new("mod_item", ObjectKind::Module), + ObjectNodeMapping::new("const_item", ObjectKind::Constant), + ObjectNodeMapping::new("static_item", ObjectKind::Variable), + ObjectNodeMapping::new("type_item", ObjectKind::Type), + ObjectNodeMapping::new("macro_definition", ObjectKind::Macro), + ObjectNodeMapping::new("use_declaration", ObjectKind::Import).no_name(), +]; + +static PYTHON_MAPPINGS: &[ObjectNodeMapping] = &[ + ObjectNodeMapping::new("function_definition", ObjectKind::Function), + ObjectNodeMapping::new("class_definition", ObjectKind::Class), + ObjectNodeMapping::new("decorated_definition", ObjectKind::Function).no_name(), + ObjectNodeMapping::new("import_statement", ObjectKind::Import).no_name(), + ObjectNodeMapping::new("import_from_statement", ObjectKind::Import).no_name(), + ObjectNodeMapping::new("assignment", ObjectKind::Variable).no_name(), +]; + +static JS_TS_MAPPINGS: &[ObjectNodeMapping] = &[ + ObjectNodeMapping::new("function_declaration", ObjectKind::Function), + ObjectNodeMapping::new("function", ObjectKind::Function), + ObjectNodeMapping::new("arrow_function", ObjectKind::Function).no_name(), + ObjectNodeMapping::new("method_definition", ObjectKind::Method), + ObjectNodeMapping::new("class_declaration", ObjectKind::Class), + ObjectNodeMapping::new("class", ObjectKind::Class), + ObjectNodeMapping::new("interface_declaration", ObjectKind::Interface), + ObjectNodeMapping::new("type_alias_declaration", ObjectKind::Type), + ObjectNodeMapping::new("enum_declaration", ObjectKind::Enum), + ObjectNodeMapping::new("import_statement", ObjectKind::Import).no_name(), + ObjectNodeMapping::new("export_statement", ObjectKind::Export).no_name(), + ObjectNodeMapping::new("variable_declaration", ObjectKind::Variable).no_name(), + ObjectNodeMapping::new("lexical_declaration", ObjectKind::Variable).no_name(), +]; + +static GO_MAPPINGS: &[ObjectNodeMapping] = &[ + ObjectNodeMapping::new("function_declaration", ObjectKind::Function), + ObjectNodeMapping::new("method_declaration", ObjectKind::Method), + ObjectNodeMapping::new("type_declaration", ObjectKind::Type).no_name(), + ObjectNodeMapping::new("type_spec", ObjectKind::Type), + ObjectNodeMapping::new("struct_type", ObjectKind::Struct).no_name(), + ObjectNodeMapping::new("interface_type", ObjectKind::Interface).no_name(), + ObjectNodeMapping::new("const_declaration", ObjectKind::Constant).no_name(), + ObjectNodeMapping::new("var_declaration", ObjectKind::Variable).no_name(), + ObjectNodeMapping::new("import_declaration", ObjectKind::Import).no_name(), +]; + +static JAVA_MAPPINGS: &[ObjectNodeMapping] = &[ + ObjectNodeMapping::new("method_declaration", ObjectKind::Method), + ObjectNodeMapping::new("constructor_declaration", ObjectKind::Function), + ObjectNodeMapping::new("class_declaration", ObjectKind::Class), + ObjectNodeMapping::new("interface_declaration", ObjectKind::Interface), + ObjectNodeMapping::new("enum_declaration", ObjectKind::Enum), + ObjectNodeMapping::new("field_declaration", ObjectKind::Field).no_name(), + ObjectNodeMapping::new("import_declaration", ObjectKind::Import).no_name(), + ObjectNodeMapping::new("constant_declaration", ObjectKind::Constant).no_name(), +]; + +static C_MAPPINGS: &[ObjectNodeMapping] = &[ + ObjectNodeMapping::new("function_definition", ObjectKind::Function) + .with_name_child("function_declarator"), + ObjectNodeMapping::new("declaration", ObjectKind::Variable).no_name(), + ObjectNodeMapping::new("struct_specifier", ObjectKind::Struct).no_name(), + ObjectNodeMapping::new("enum_specifier", ObjectKind::Enum).no_name(), + ObjectNodeMapping::new("type_definition", ObjectKind::Type).no_name(), + ObjectNodeMapping::new("preproc_def", ObjectKind::Macro), + ObjectNodeMapping::new("preproc_function_def", ObjectKind::Macro), +]; + +static CPP_MAPPINGS: &[ObjectNodeMapping] = &[ + ObjectNodeMapping::new("function_definition", ObjectKind::Function) + .with_name_child("function_declarator"), + ObjectNodeMapping::new("declaration", ObjectKind::Variable).no_name(), + ObjectNodeMapping::new("class_specifier", ObjectKind::Class), + ObjectNodeMapping::new("struct_specifier", ObjectKind::Struct).no_name(), + ObjectNodeMapping::new("enum_specifier", ObjectKind::Enum).no_name(), + ObjectNodeMapping::new("namespace_definition", ObjectKind::Module), + ObjectNodeMapping::new("template_declaration", ObjectKind::Type).no_name(), + ObjectNodeMapping::new("type_definition", ObjectKind::Type).no_name(), + ObjectNodeMapping::new("preproc_def", ObjectKind::Macro), + ObjectNodeMapping::new("preproc_function_def", ObjectKind::Macro), +]; + +static RUBY_MAPPINGS: &[ObjectNodeMapping] = &[ + ObjectNodeMapping::new("method", ObjectKind::Method), + ObjectNodeMapping::new("singleton_method", ObjectKind::Method), + ObjectNodeMapping::new("class", ObjectKind::Class), + ObjectNodeMapping::new("module", ObjectKind::Module), + ObjectNodeMapping::new("constant", ObjectKind::Constant).no_name(), +]; + +static CONFIG_MAPPINGS: &[ObjectNodeMapping] = &[ + ObjectNodeMapping::new("pair", ObjectKind::Property).no_name(), + ObjectNodeMapping::new("table", ObjectKind::Section).no_name(), + ObjectNodeMapping::new("block_mapping_pair", ObjectKind::Property).no_name(), +]; + +static HTML_MAPPINGS: &[ObjectNodeMapping] = &[ + ObjectNodeMapping::new("element", ObjectKind::Section).no_name(), + ObjectNodeMapping::new("script_element", ObjectKind::Section).no_name(), + ObjectNodeMapping::new("style_element", ObjectKind::Section).no_name(), +]; + +static CSS_MAPPINGS: &[ObjectNodeMapping] = &[ + ObjectNodeMapping::new("rule_set", ObjectKind::Rule).no_name(), + ObjectNodeMapping::new("media_statement", ObjectKind::Rule).no_name(), + ObjectNodeMapping::new("keyframes_statement", ObjectKind::Rule).no_name(), +]; + +static BASH_MAPPINGS: &[ObjectNodeMapping] = &[ + ObjectNodeMapping::new("function_definition", ObjectKind::Function), + ObjectNodeMapping::new("variable_assignment", ObjectKind::Variable).no_name(), +]; + +static MARKDOWN_MAPPINGS: &[ObjectNodeMapping] = &[ + ObjectNodeMapping::new("atx_heading", ObjectKind::Section).no_name(), + ObjectNodeMapping::new("setext_heading", ObjectKind::Section).no_name(), + ObjectNodeMapping::new("fenced_code_block", ObjectKind::Section).no_name(), +]; + +#[derive(Debug, Clone)] +pub struct ParsedObject { + pub name: String, + pub kind: ObjectKind, + pub line_start: usize, + pub line_end: usize, + pub byte_start: usize, + pub byte_end: usize, + pub visibility: Option, +} + +pub struct TreeSitterParser { + parser: Parser, +} + +impl TreeSitterParser { + pub fn new() -> Self { + Self { + parser: Parser::new(), + } + } + + pub fn supports(path: &str) -> bool { + Lang::from_path(path).is_some() + } + + pub fn parse(&mut self, source: &str, lang: Lang) -> Result, String> { + self.parser + .set_language(&lang.tree_sitter_language()) + .map_err(|e| format!("Failed to set language: {}", e))?; + + let tree = self + .parser + .parse(source, None) + .ok_or("Failed to parse source code")?; + + Ok(self.extract_objects(&tree, source.as_bytes(), lang)) + } + + pub fn parse_file(&mut self, path: &str) -> Result<(Lang, Vec), String> { + let lang = Lang::from_path(path) + .ok_or_else(|| format!("Unsupported file extension: {}", path))?; + + let source = std::fs::read_to_string(path) + .map_err(|e| format!("Failed to read file: {}", e))?; + + let objects = self.parse(&source, lang)?; + Ok((lang, objects)) + } + + fn extract_objects(&self, tree: &Tree, source: &[u8], lang: Lang) -> Vec { + let mut objects = Vec::new(); + let mappings = lang.object_node_types(); + + self.visit_node(tree.root_node(), source, mappings, &mut objects, lang); + + objects + } + + fn visit_node( + &self, + node: tree_sitter::Node, + source: &[u8], + mappings: &[ObjectNodeMapping], + objects: &mut Vec, + lang: Lang, + ) { + let node_type = node.kind(); + + if let Some(mapping) = mappings.iter().find(|m| m.node_type == node_type) { + if let Some(obj) = self.extract_object(node, source, mapping, lang) { + objects.push(obj); + } + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + self.visit_node(child, source, mappings, objects, lang); + } + } + + fn extract_object( + &self, + node: tree_sitter::Node, + source: &[u8], + mapping: &ObjectNodeMapping, + lang: Lang, + ) -> Option { + let name = self.extract_name(node, source, mapping, lang)?; + let visibility = self.extract_visibility(node, source, lang); + + Some(ParsedObject { + name, + kind: mapping.kind, + line_start: node.start_position().row + 1, + line_end: node.end_position().row + 1, + byte_start: node.start_byte(), + byte_end: node.end_byte(), + visibility, + }) + } + + fn extract_name( + &self, + node: tree_sitter::Node, + source: &[u8], + mapping: &ObjectNodeMapping, + lang: Lang, + ) -> Option { + if let Some(field) = mapping.name_field { + if let Some(name_node) = node.child_by_field_name(field) { + return name_node.utf8_text(source).ok().map(|s| s.to_string()); + } + } + + if let Some(child_type) = mapping.name_child_type { + return self.find_name_in_children(node, source, child_type); + } + + match lang { + Lang::Rust if mapping.kind == ObjectKind::Impl => { + self.extract_rust_impl_name(node, source) + } + _ => self.extract_fallback_name(node, source, mapping.kind), + } + } + + fn find_name_in_children( + &self, + node: tree_sitter::Node, + source: &[u8], + target_type: &str, + ) -> Option { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == target_type { + if let Some(name_node) = child.child_by_field_name("name") { + return name_node.utf8_text(source).ok().map(|s| s.to_string()); + } + if let Some(ident) = child.child_by_field_name("declarator") { + return self.find_identifier(ident, source); + } + return child.utf8_text(source).ok().map(|s| s.to_string()); + } + + if let Some(name) = self.find_name_in_children(child, source, target_type) { + return Some(name); + } + } + None + } + + fn find_identifier(&self, node: tree_sitter::Node, source: &[u8]) -> Option { + if node.kind() == "identifier" { + return node.utf8_text(source).ok().map(|s| s.to_string()); + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if let Some(name) = self.find_identifier(child, source) { + return Some(name); + } + } + None + } + + fn extract_rust_impl_name(&self, node: tree_sitter::Node, source: &[u8]) -> Option { + let mut cursor = node.walk(); + let mut trait_name: Option = None; + let mut type_name: Option = None; + + for child in node.children(&mut cursor) { + match child.kind() { + "type_identifier" | "generic_type" | "scoped_type_identifier" => { + let text = child.utf8_text(source).ok()?.to_string(); + if trait_name.is_none() && type_name.is_none() { + type_name = Some(text); + } else if type_name.is_some() && trait_name.is_none() { + trait_name = type_name.take(); + type_name = Some(text); + } + } + _ => {} + } + } + + match (&trait_name, &type_name) { + (Some(t), Some(ty)) => Some(format!("{} for {}", t, ty)), + (None, Some(ty)) => Some(ty.clone()), + _ => Some("".to_string()), + } + } + + fn extract_fallback_name( + &self, + node: tree_sitter::Node, + source: &[u8], + kind: ObjectKind, + ) -> Option { + let text = node.utf8_text(source).ok()?; + let preview: String = text.chars().take(50).collect(); + let preview = preview.lines().next().unwrap_or(&preview); + + Some(format!("<{}: {}>", kind.name(), preview.trim())) + } + + fn extract_visibility( + &self, + node: tree_sitter::Node, + source: &[u8], + lang: Lang, + ) -> Option { + match lang { + Lang::Rust => { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "visibility_modifier" { + return child.utf8_text(source).ok().map(|s| s.to_string()); + } + } + None + } + Lang::Java | Lang::TypeScript | Lang::Tsx => { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "modifiers" || child.kind().contains("modifier") { + let text = child.utf8_text(source).ok()?; + if text.contains("public") + || text.contains("private") + || text.contains("protected") + { + return Some(text.to_string()); + } + } + } + None + } + Lang::Python => { + if let Some(name_node) = node.child_by_field_name("name") { + let name = name_node.utf8_text(source).ok()?; + if name.starts_with("__") && !name.ends_with("__") { + return Some("private".to_string()); + } else if name.starts_with("_") { + return Some("protected".to_string()); + } + } + None + } + _ => None, + } + } +} + +impl Default for TreeSitterParser { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lang_detection() { + assert_eq!(Lang::from_extension("rs"), Some(Lang::Rust)); + assert_eq!(Lang::from_extension("py"), Some(Lang::Python)); + assert_eq!(Lang::from_extension("js"), Some(Lang::JavaScript)); + assert_eq!(Lang::from_extension("ts"), Some(Lang::TypeScript)); + assert_eq!(Lang::from_extension("go"), Some(Lang::Go)); + assert_eq!(Lang::from_extension("java"), Some(Lang::Java)); + assert_eq!(Lang::from_extension("php"), None); + assert_eq!(Lang::from_extension("kt"), None); + } + + #[test] + fn test_parse_rust() { + let source = r#" +pub fn hello() {} +struct Point { x: i32, y: i32 } +impl Point { + fn new() -> Self { Self { x: 0, y: 0 } } +} +"#; + let mut parser = TreeSitterParser::new(); + let objects = parser.parse(source, Lang::Rust).unwrap(); + + assert!(objects.iter().any(|o| o.name == "hello")); + assert!(objects.iter().any(|o| o.name == "Point")); + assert!(objects.iter().any(|o| o.name == "new")); + } + + #[test] + fn test_parse_python() { + let source = r#" +def hello(): + pass + +class MyClass: + def method(self): + pass +"#; + let mut parser = TreeSitterParser::new(); + let objects = parser.parse(source, Lang::Python).unwrap(); + + assert!(objects.iter().any(|o| o.name == "hello")); + assert!(objects.iter().any(|o| o.name == "MyClass")); + assert!(objects.iter().any(|o| o.name == "method")); + } + + #[test] + fn test_parse_javascript() { + let source = r#" +function hello() {} +class MyClass { + method() {} +} +const x = 1; +"#; + let mut parser = TreeSitterParser::new(); + let objects = parser.parse(source, Lang::JavaScript).unwrap(); + + assert!(objects.iter().any(|o| o.name == "hello")); + assert!(objects.iter().any(|o| o.name == "MyClass")); + } +} diff --git a/src/utils/shortcuts.rs b/src/utils/shortcuts.rs new file mode 100644 index 0000000..5a46d4f --- /dev/null +++ b/src/utils/shortcuts.rs @@ -0,0 +1,28 @@ +use rustyline::error::ReadlineError; + +/// Handle readline errors, including shortcut-triggered exits +/// Ctrl+C triggers Interrupted, Ctrl+D and Ctrl+Q trigger EOF +pub fn handle_readline_error(err: ReadlineError) -> Result { + match err { + ReadlineError::Interrupted => { + // Ctrl+C - cancel current operation + Ok(Action::Cancel) + } + ReadlineError::Eof => { + // Ctrl+D or Ctrl+Q - quit the application + Ok(Action::Quit) + } + err => Err(format!("Readline error: {:?}", err)), + } +} + +/// Actions that can be triggered by keyboard shortcuts +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + /// Continue normal operation + Continue, + /// Cancel current operation (Ctrl+C) + Cancel, + /// Quit the application (Ctrl+Q, Ctrl+D) + Quit, +}