From 16e3eb20676560b08b738792f4b4eb8bae195f0d Mon Sep 17 00:00:00 2001 From: Mo Abualruz Date: Mon, 1 Dec 2025 12:10:19 +0100 Subject: [PATCH 1/2] Implement RiceCoder Storage Module with Global and Project Storage Management - Introduced core storage module with global and project storage capabilities. - Added `StorageManager` trait for managing storage operations. - Implemented `PathResolver` for cross-platform path resolution. - Developed `ProjectStore` for managing project-local knowledge bases. - Created core types for storage configuration and resource management. - Added property-based tests for first-run confirmation, global resource storage, and project storage consistency. - Implemented tests for RICECODER_HOME environment variable override. --- CONTRIBUTING.md | 12 +- LICENSE.md | 12 +- README.md | Bin 7688 -> 7688 bytes crates/ricecoder-storage/Cargo.lock | 942 ++++++++++++++++++ crates/ricecoder-storage/Cargo.toml | 28 + crates/ricecoder-storage/src/error.rs | 168 ++++ crates/ricecoder-storage/src/first_run.rs | 136 +++ crates/ricecoder-storage/src/global_store.rs | 280 ++++++ crates/ricecoder-storage/src/lib.rs | 20 + crates/ricecoder-storage/src/manager.rs | 129 +++ crates/ricecoder-storage/src/project_store.rs | 319 ++++++ crates/ricecoder-storage/src/types.rs | 178 ++++ .../tests/property_first_run_confirmation.rs | 140 +++ .../tests/property_global_resource_storage.rs | 192 ++++ .../tests/property_project_storage.rs | 216 ++++ ...cecoder_home_override.proptest-regressions | 9 + .../tests/property_ricecoder_home_override.rs | 87 ++ 17 files changed, 2856 insertions(+), 12 deletions(-) create mode 100644 crates/ricecoder-storage/Cargo.lock create mode 100644 crates/ricecoder-storage/Cargo.toml create mode 100644 crates/ricecoder-storage/src/error.rs create mode 100644 crates/ricecoder-storage/src/first_run.rs create mode 100644 crates/ricecoder-storage/src/global_store.rs create mode 100644 crates/ricecoder-storage/src/lib.rs create mode 100644 crates/ricecoder-storage/src/manager.rs create mode 100644 crates/ricecoder-storage/src/project_store.rs create mode 100644 crates/ricecoder-storage/src/types.rs create mode 100644 crates/ricecoder-storage/tests/property_first_run_confirmation.rs create mode 100644 crates/ricecoder-storage/tests/property_global_resource_storage.rs create mode 100644 crates/ricecoder-storage/tests/property_project_storage.rs create mode 100644 crates/ricecoder-storage/tests/property_ricecoder_home_override.proptest-regressions create mode 100644 crates/ricecoder-storage/tests/property_ricecoder_home_override.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8d2a5ecb..74e0edf2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,12 +91,12 @@ test: add property tests for template rendering ``` Prefixes: -- `feat:` — New feature -- `fix:` — Bug fix -- `docs:` — Documentation -- `refactor:` — Code refactoring -- `test:` — Tests -- `chore:` — Maintenance +- `feat:` - New feature +- `fix:` - Bug fix +- `docs:` - Documentation +- `refactor:` - Code refactoring +- `test:` - Tests +- `chore:` - Maintenance --- diff --git a/LICENSE.md b/LICENSE.md index 176d8ab3..eace3bef 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -8,18 +8,18 @@ Copyright (c) 2025 RiceCoder Contributors ### You are free to: -- **Share** — Copy and redistribute the material in any medium or format -- **Adapt** — Remix, transform, and build upon the material +- **Share** - Copy and redistribute the material in any medium or format +- **Adapt** - Remix, transform, and build upon the material ### Under the following terms: -1. **Attribution** — You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use. +1. **Attribution** - You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use. -2. **NonCommercial** — You may not use the material for commercial purposes without obtaining a commercial license. +2. **NonCommercial** - You may not use the material for commercial purposes without obtaining a commercial license. -3. **ShareAlike** — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original. +3. **ShareAlike** - If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original. -4. **No additional restrictions** — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits. +4. **No additional restrictions** - You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits. --- diff --git a/README.md b/README.md index 7ce301030d17568159e6fa878545e7da31cc4be2..9ee471d4f1dfcb35d4b62a6eb41813eb546ab7bd 100644 GIT binary patch delta 81 zcmeCM>9E-l!o{S^usMv&h!IFn?&6*SqEvX6fT&YEbs)-%7cBUMw~1MoL1A*Qgz@G% Yd|n)kx|1&oif;ZRc7zS2>X57(0EBZFB>(^b delta 78 zcmeCM>9E-l!o?(_usMv&h!IFn?&6*SqEvX6fT&YEbs)-%7cBUMw~1LqL1A*Qgz@G% Ud|n(tsm(9MZm@xr9Flbd074)Y+W-In diff --git a/crates/ricecoder-storage/Cargo.lock b/crates/ricecoder-storage/Cargo.lock new file mode 100644 index 00000000..6781fad4 --- /dev/null +++ b/crates/ricecoder-storage/Cargo.lock @@ -0,0 +1,942 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +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 = "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 = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[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 = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +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 = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[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-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "ricecoder-storage" +version = "0.1.0" +dependencies = [ + "anyhow", + "dirs", + "lazy_static", + "proptest", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "thiserror", + "tokio", + "tokio-test", + "toml", + "tracing", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +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 = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +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.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[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 = "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.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +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.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[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.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/crates/ricecoder-storage/Cargo.toml b/crates/ricecoder-storage/Cargo.toml new file mode 100644 index 00000000..5acd8ffb --- /dev/null +++ b/crates/ricecoder-storage/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "ricecoder-storage" +version = "0.1.0" +edition = "2021" +authors = ["RiceCoder Contributors"] +description = "Storage and configuration management for RiceCoder" +license = "MIT" + +[lib] +name = "ricecoder_storage" +path = "src/lib.rs" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" +toml = "0.8" +thiserror = "1.0" +tracing = "0.1" +dirs = "5.0" +tokio = { version = "1.0", features = ["full"] } +anyhow = "1.0" + +[dev-dependencies] +proptest = "1.4" +tokio-test = "0.4" +tempfile = "3.8" +lazy_static = "1.4" diff --git a/crates/ricecoder-storage/src/error.rs b/crates/ricecoder-storage/src/error.rs new file mode 100644 index 00000000..654e4620 --- /dev/null +++ b/crates/ricecoder-storage/src/error.rs @@ -0,0 +1,168 @@ +//! Storage error types for RiceCoder + +use std::path::PathBuf; +use thiserror::Error; + +/// Result type for storage operations +pub type StorageResult = Result; + +/// Storage error types +#[derive(Error, Debug)] +pub enum StorageError { + /// Directory creation failed + #[error("Directory creation failed for {path}: {source}")] + DirectoryCreationFailed { + path: PathBuf, + source: std::io::Error, + }, + + /// File read/write failed + #[error("IO error on {path} ({operation}): {source}")] + IoError { + path: PathBuf, + operation: IoOperation, + source: std::io::Error, + }, + + /// Configuration parsing failed + #[error("Failed to parse {path} as {format}: {message}")] + ParseError { + path: PathBuf, + format: String, + message: String, + }, + + /// Invalid configuration value + #[error("Invalid configuration value for {field}: {message}")] + ValidationError { field: String, message: String }, + + /// Path resolution failed + #[error("Path resolution failed: {message}")] + PathResolutionError { message: String }, + + /// Environment variable error + #[error("Environment variable error for {var_name}: {message}")] + EnvVarError { + var_name: String, + message: String, + }, + + /// Relocation failed + #[error("Failed to relocate storage from {from} to {to}: {message}")] + RelocationError { + from: PathBuf, + to: PathBuf, + message: String, + }, + + /// Offline mode - storage unavailable + #[error("Storage unavailable at {path}: {message}")] + StorageUnavailable { path: PathBuf, message: String }, + + /// First-run confirmation required + #[error("First-run confirmation required. Suggested path: {suggested_path}")] + FirstRunConfirmationRequired { suggested_path: PathBuf }, + + /// Generic IO error + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + /// Internal error + #[error("Internal error: {0}")] + Internal(String), +} + +/// IO operation type for error context +#[derive(Debug, Clone, Copy)] +pub enum IoOperation { + Read, + Write, + Delete, + Move, +} + +impl std::fmt::Display for IoOperation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + IoOperation::Read => write!(f, "read"), + IoOperation::Write => write!(f, "write"), + IoOperation::Delete => write!(f, "delete"), + IoOperation::Move => write!(f, "move"), + } + } +} + +impl StorageError { + /// Create a directory creation failed error + pub fn directory_creation_failed(path: PathBuf, source: std::io::Error) -> Self { + StorageError::DirectoryCreationFailed { path, source } + } + + /// Create an IO error + pub fn io_error(path: PathBuf, operation: IoOperation, source: std::io::Error) -> Self { + StorageError::IoError { + path, + operation, + source, + } + } + + /// Create a parse error + pub fn parse_error(path: PathBuf, format: impl Into, message: impl Into) -> Self { + StorageError::ParseError { + path, + format: format.into(), + message: message.into(), + } + } + + /// Create a validation error + pub fn validation_error(field: impl Into, message: impl Into) -> Self { + StorageError::ValidationError { + field: field.into(), + message: message.into(), + } + } + + /// Create a path resolution error + pub fn path_resolution_error(message: impl Into) -> Self { + StorageError::PathResolutionError { + message: message.into(), + } + } + + /// Create an environment variable error + pub fn env_var_error(var_name: impl Into, message: impl Into) -> Self { + StorageError::EnvVarError { + var_name: var_name.into(), + message: message.into(), + } + } + + /// Create a relocation error + pub fn relocation_error(from: PathBuf, to: PathBuf, message: impl Into) -> Self { + StorageError::RelocationError { + from, + to, + message: message.into(), + } + } + + /// Create a storage unavailable error + pub fn storage_unavailable(path: PathBuf, message: impl Into) -> Self { + StorageError::StorageUnavailable { + path, + message: message.into(), + } + } + + /// Create a first-run confirmation required error + pub fn first_run_confirmation_required(suggested_path: PathBuf) -> Self { + StorageError::FirstRunConfirmationRequired { suggested_path } + } + + /// Create an internal error + pub fn internal(message: impl Into) -> Self { + StorageError::Internal(message.into()) + } +} diff --git a/crates/ricecoder-storage/src/first_run.rs b/crates/ricecoder-storage/src/first_run.rs new file mode 100644 index 00000000..272f2544 --- /dev/null +++ b/crates/ricecoder-storage/src/first_run.rs @@ -0,0 +1,136 @@ +//! First-run initialization and storage confirmation +//! +//! Handles first-time initialization of RiceCoder storage, +//! including user confirmation of storage location. + +use crate::error::StorageResult; +use crate::manager::PathResolver; +use std::fs; +use std::path::PathBuf; + +/// Marker file name for tracking first-run status +const FIRST_RUN_MARKER: &str = ".ricecoder-initialized"; + +/// First-run handler for storage initialization +pub struct FirstRunHandler; + +impl FirstRunHandler { + /// Check if this is the first run + /// + /// Returns true if the marker file doesn't exist in the global storage path + pub fn is_first_run(global_path: &PathBuf) -> StorageResult { + let marker_path = global_path.join(FIRST_RUN_MARKER); + Ok(!marker_path.exists()) + } + + /// Mark the first run as complete + /// + /// Creates the marker file to indicate initialization is done + pub fn mark_first_run_complete(global_path: &PathBuf) -> StorageResult<()> { + let marker_path = global_path.join(FIRST_RUN_MARKER); + + // Ensure parent directory exists + if let Some(parent) = marker_path.parent() { + if !parent.exists() { + fs::create_dir_all(parent).map_err(|e| { + crate::error::StorageError::directory_creation_failed( + parent.to_path_buf(), + e, + ) + })?; + } + } + + // Create the marker file + fs::write(&marker_path, "").map_err(|e| { + crate::error::StorageError::io_error( + marker_path, + crate::error::IoOperation::Write, + e, + ) + })?; + + Ok(()) + } + + /// Get the suggested global storage path + /// + /// Returns the path that would be used for global storage + pub fn get_suggested_path() -> StorageResult { + PathResolver::resolve_global_path() + } + + /// Detect first-time initialization + /// + /// Returns true if: + /// - The global storage directory doesn't exist, OR + /// - The marker file doesn't exist in the global storage directory + pub fn detect_first_run() -> StorageResult { + let global_path = PathResolver::resolve_global_path()?; + + if !global_path.exists() { + return Ok(true); + } + + Self::is_first_run(&global_path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_is_first_run_no_marker() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let is_first = FirstRunHandler::is_first_run(&temp_dir.path().to_path_buf()) + .expect("Failed to check first run"); + assert!(is_first, "Should be first run when marker doesn't exist"); + } + + #[test] + fn test_is_first_run_with_marker() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let marker_path = temp_dir.path().join(FIRST_RUN_MARKER); + fs::write(&marker_path, "").expect("Failed to create marker"); + + let is_first = FirstRunHandler::is_first_run(&temp_dir.path().to_path_buf()) + .expect("Failed to check first run"); + assert!(!is_first, "Should not be first run when marker exists"); + } + + #[test] + fn test_mark_first_run_complete() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let path = temp_dir.path().to_path_buf(); + + // Initially should be first run + let is_first_before = FirstRunHandler::is_first_run(&path) + .expect("Failed to check first run"); + assert!(is_first_before); + + // Mark as complete + FirstRunHandler::mark_first_run_complete(&path) + .expect("Failed to mark first run complete"); + + // Should no longer be first run + let is_first_after = FirstRunHandler::is_first_run(&path) + .expect("Failed to check first run"); + assert!(!is_first_after); + } + + #[test] + fn test_get_suggested_path() { + let path = FirstRunHandler::get_suggested_path() + .expect("Failed to get suggested path"); + assert!(path.to_string_lossy().contains(".ricecoder")); + } + + #[test] + fn test_detect_first_run_nonexistent_dir() { + // This test uses the actual path resolution, so we just verify it returns a result + let result = FirstRunHandler::detect_first_run(); + assert!(result.is_ok(), "Should successfully detect first run status"); + } +} diff --git a/crates/ricecoder-storage/src/global_store.rs b/crates/ricecoder-storage/src/global_store.rs new file mode 100644 index 00000000..b57bec52 --- /dev/null +++ b/crates/ricecoder-storage/src/global_store.rs @@ -0,0 +1,280 @@ +//! Global storage implementation for RiceCoder +//! +//! Manages the global knowledge base stored in ~/Documents/.ricecoder/ + +use crate::error::{IoOperation, StorageError, StorageResult}; +use crate::manager::PathResolver; +use crate::types::ResourceType; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Global store for managing global knowledge base +pub struct GlobalStore { + /// Path to the global storage directory + base_path: PathBuf, +} + +impl GlobalStore { + /// Create a new global store + pub fn new(base_path: PathBuf) -> Self { + GlobalStore { base_path } + } + + /// Create a new global store with default path resolution + pub fn with_default_path() -> StorageResult { + let base_path = PathResolver::resolve_global_path()?; + Ok(GlobalStore { base_path }) + } + + /// Get the base path + pub fn base_path(&self) -> &PathBuf { + &self.base_path + } + + /// Initialize the global store directory structure + /// + /// Creates the base directory and all resource subdirectories: + /// - templates/ + /// - standards/ + /// - specs/ + /// - steering/ + /// - boilerplates/ + /// - rules/ + /// - cache/ + pub fn initialize(&self) -> StorageResult<()> { + // Create base directory + self.create_dir_if_not_exists(&self.base_path)?; + + // Create resource directories + for resource_type in &[ + ResourceType::Template, + ResourceType::Standard, + ResourceType::Spec, + ResourceType::Steering, + ResourceType::Boilerplate, + ResourceType::Rule, + ] { + let resource_path = self.resource_path(*resource_type); + self.create_dir_if_not_exists(&resource_path)?; + } + + // Create cache directory + let cache_path = self.base_path.join("cache"); + self.create_dir_if_not_exists(&cache_path)?; + + Ok(()) + } + + /// Get the path for a resource type + pub fn resource_path(&self, resource_type: ResourceType) -> PathBuf { + self.base_path.join(resource_type.dir_name()) + } + + /// Store a resource file + pub fn store_resource( + &self, + resource_type: ResourceType, + name: &str, + content: &[u8], + ) -> StorageResult { + let resource_dir = self.resource_path(resource_type); + let file_path = resource_dir.join(name); + + // Ensure directory exists + self.create_dir_if_not_exists(&resource_dir)?; + + // Write file + fs::write(&file_path, content).map_err(|e| { + StorageError::io_error(file_path.clone(), IoOperation::Write, e) + })?; + + Ok(file_path) + } + + /// Retrieve a resource file + pub fn retrieve_resource( + &self, + resource_type: ResourceType, + name: &str, + ) -> StorageResult> { + let resource_dir = self.resource_path(resource_type); + let file_path = resource_dir.join(name); + + fs::read(&file_path).map_err(|e| { + StorageError::io_error(file_path, IoOperation::Read, e) + }) + } + + /// List all resources of a type + pub fn list_resources(&self, resource_type: ResourceType) -> StorageResult> { + let resource_dir = self.resource_path(resource_type); + + if !resource_dir.exists() { + return Ok(Vec::new()); + } + + let mut resources = Vec::new(); + let entries = fs::read_dir(&resource_dir).map_err(|e| { + StorageError::io_error(resource_dir.clone(), IoOperation::Read, e) + })?; + + for entry in entries { + let entry = entry.map_err(|e| { + StorageError::io_error(resource_dir.clone(), IoOperation::Read, e) + })?; + + let path = entry.path(); + if path.is_file() { + if let Some(file_name) = path.file_name() { + if let Some(name_str) = file_name.to_str() { + resources.push(name_str.to_string()); + } + } + } + } + + Ok(resources) + } + + /// Delete a resource file + pub fn delete_resource( + &self, + resource_type: ResourceType, + name: &str, + ) -> StorageResult<()> { + let resource_dir = self.resource_path(resource_type); + let file_path = resource_dir.join(name); + + if file_path.exists() { + fs::remove_file(&file_path).map_err(|e| { + StorageError::io_error(file_path, IoOperation::Delete, e) + })?; + } + + Ok(()) + } + + /// Check if a resource exists + pub fn resource_exists(&self, resource_type: ResourceType, name: &str) -> bool { + let resource_dir = self.resource_path(resource_type); + let file_path = resource_dir.join(name); + file_path.exists() + } + + /// Create a directory if it doesn't exist + fn create_dir_if_not_exists(&self, path: &Path) -> StorageResult<()> { + if !path.exists() { + fs::create_dir_all(path).map_err(|e| { + StorageError::directory_creation_failed(path.to_path_buf(), e) + })?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_global_store_initialization() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = GlobalStore::new(temp_dir.path().to_path_buf()); + + store.initialize().expect("Failed to initialize store"); + + // Verify all directories were created + assert!(store.resource_path(ResourceType::Template).exists()); + assert!(store.resource_path(ResourceType::Standard).exists()); + assert!(store.resource_path(ResourceType::Spec).exists()); + assert!(store.resource_path(ResourceType::Steering).exists()); + assert!(store.resource_path(ResourceType::Boilerplate).exists()); + assert!(store.resource_path(ResourceType::Rule).exists()); + assert!(temp_dir.path().join("cache").exists()); + } + + #[test] + fn test_store_and_retrieve_resource() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = GlobalStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + let content = b"test content"; + let name = "test.txt"; + + // Store resource + let path = store + .store_resource(ResourceType::Template, name, content) + .expect("Failed to store resource"); + + assert!(path.exists()); + + // Retrieve resource + let retrieved = store + .retrieve_resource(ResourceType::Template, name) + .expect("Failed to retrieve resource"); + + assert_eq!(retrieved, content); + } + + #[test] + fn test_list_resources() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = GlobalStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + // Store multiple resources + store + .store_resource(ResourceType::Template, "template1.txt", b"content1") + .expect("Failed to store"); + store + .store_resource(ResourceType::Template, "template2.txt", b"content2") + .expect("Failed to store"); + + // List resources + let resources = store + .list_resources(ResourceType::Template) + .expect("Failed to list resources"); + + assert_eq!(resources.len(), 2); + assert!(resources.contains(&"template1.txt".to_string())); + assert!(resources.contains(&"template2.txt".to_string())); + } + + #[test] + fn test_delete_resource() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = GlobalStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + let name = "test.txt"; + store + .store_resource(ResourceType::Template, name, b"content") + .expect("Failed to store"); + + assert!(store.resource_exists(ResourceType::Template, name)); + + store + .delete_resource(ResourceType::Template, name) + .expect("Failed to delete"); + + assert!(!store.resource_exists(ResourceType::Template, name)); + } + + #[test] + fn test_resource_exists() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = GlobalStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + let name = "test.txt"; + assert!(!store.resource_exists(ResourceType::Template, name)); + + store + .store_resource(ResourceType::Template, name, b"content") + .expect("Failed to store"); + + assert!(store.resource_exists(ResourceType::Template, name)); + } +} diff --git a/crates/ricecoder-storage/src/lib.rs b/crates/ricecoder-storage/src/lib.rs new file mode 100644 index 00000000..6b3e96e4 --- /dev/null +++ b/crates/ricecoder-storage/src/lib.rs @@ -0,0 +1,20 @@ +//! RiceCoder Storage and Configuration Module +//! +//! This module provides storage and configuration management for RiceCoder, +//! including global and project-local knowledge bases, configuration loading, +//! and data persistence. + +pub mod error; +pub mod first_run; +pub mod global_store; +pub mod manager; +pub mod project_store; +pub mod types; + +// Re-export commonly used types +pub use error::{IoOperation, StorageError, StorageResult}; +pub use first_run::FirstRunHandler; +pub use global_store::GlobalStore; +pub use manager::{PathResolver, StorageManager}; +pub use project_store::ProjectStore; +pub use types::{ConfigFormat, DocumentFormat, ResourceType, StorageConfig, StorageMode, StorageState}; diff --git a/crates/ricecoder-storage/src/manager.rs b/crates/ricecoder-storage/src/manager.rs new file mode 100644 index 00000000..58e95340 --- /dev/null +++ b/crates/ricecoder-storage/src/manager.rs @@ -0,0 +1,129 @@ +//! Storage manager trait and path resolution + +use crate::error::{StorageError, StorageResult}; +use crate::types::{ResourceType, StorageMode}; +use std::path::PathBuf; + +/// Storage manager trait for managing storage operations +pub trait StorageManager: Send + Sync { + /// Get the global storage path + fn global_path(&self) -> &PathBuf; + + /// Get the project storage path (if in a project) + fn project_path(&self) -> Option<&PathBuf>; + + /// Get the current storage mode + fn mode(&self) -> StorageMode; + + /// Get the path for a resource type in global storage + fn global_resource_path(&self, resource_type: ResourceType) -> PathBuf; + + /// Get the path for a resource type in project storage + fn project_resource_path(&self, resource_type: ResourceType) -> Option; + + /// Check if this is the first run + fn is_first_run(&self) -> bool; +} + +/// Path resolver for cross-platform storage paths +pub struct PathResolver; + +impl PathResolver { + /// Resolve the global storage path based on OS and environment + /// + /// Priority: + /// 1. RICECODER_HOME environment variable + /// 2. ~/Documents/.ricecoder/ (primary) + /// 3. ~/.ricecoder/ (fallback if Documents doesn't exist) + pub fn resolve_global_path() -> StorageResult { + // Check for RICECODER_HOME environment variable + if let Ok(home_override) = std::env::var("RICECODER_HOME") { + let path = PathBuf::from(home_override); + return Ok(path); + } + + // Try Documents folder first + if let Some(docs_dir) = dirs::document_dir() { + let ricecoder_path = docs_dir.join(".ricecoder"); + return Ok(ricecoder_path); + } + + // Fallback to home directory + if let Some(home_dir) = dirs::home_dir() { + let ricecoder_path = home_dir.join(".ricecoder"); + return Ok(ricecoder_path); + } + + Err(StorageError::path_resolution_error( + "Could not determine home directory", + )) + } + + /// Resolve the project storage path (./.agent/) + pub fn resolve_project_path() -> PathBuf { + PathBuf::from(".agent") + } + + /// Expand ~ in paths to home directory + pub fn expand_home(path: &PathBuf) -> StorageResult { + let path_str = path + .to_str() + .ok_or_else(|| StorageError::path_resolution_error("Invalid path encoding"))?; + + if path_str.starts_with("~") { + if let Some(home_dir) = dirs::home_dir() { + let expanded = if path_str == "~" { + home_dir + } else { + home_dir.join(&path_str[2..]) + }; + return Ok(expanded); + } + } + + Ok(path.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_global_path_with_env_override() { + // Set RICECODER_HOME environment variable + std::env::set_var("RICECODER_HOME", "/tmp/ricecoder-test"); + let path = PathResolver::resolve_global_path().expect("Should resolve path"); + assert_eq!(path, PathBuf::from("/tmp/ricecoder-test")); + std::env::remove_var("RICECODER_HOME"); + } + + #[test] + fn test_resolve_global_path_without_env() { + // Ensure RICECODER_HOME is not set + std::env::remove_var("RICECODER_HOME"); + let path = PathResolver::resolve_global_path().expect("Should resolve path"); + // Should be either Documents/.ricecoder or ~/.ricecoder + assert!(path.to_string_lossy().contains(".ricecoder")); + } + + #[test] + fn test_resolve_project_path() { + let path = PathResolver::resolve_project_path(); + assert_eq!(path, PathBuf::from(".agent")); + } + + #[test] + fn test_expand_home_with_tilde() { + let path = PathBuf::from("~/.ricecoder"); + let expanded = PathResolver::expand_home(&path).expect("Should expand"); + assert!(!expanded.to_string_lossy().contains("~")); + } + + #[test] + fn test_expand_home_without_tilde() { + let path = PathBuf::from("/tmp/ricecoder"); + let expanded = PathResolver::expand_home(&path).expect("Should expand"); + assert_eq!(expanded, path); + } +} diff --git a/crates/ricecoder-storage/src/project_store.rs b/crates/ricecoder-storage/src/project_store.rs new file mode 100644 index 00000000..76faf606 --- /dev/null +++ b/crates/ricecoder-storage/src/project_store.rs @@ -0,0 +1,319 @@ +//! Project storage implementation for RiceCoder +//! +//! Manages the project-local knowledge base stored in ./.agent/ + +use crate::error::{IoOperation, StorageError, StorageResult}; +use crate::types::ResourceType; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Project store for managing project-local knowledge base +pub struct ProjectStore { + /// Path to the project storage directory (./.agent/) + base_path: PathBuf, +} + +impl ProjectStore { + /// Create a new project store + pub fn new(base_path: PathBuf) -> Self { + ProjectStore { base_path } + } + + /// Create a new project store with default path (./.agent/) + pub fn with_default_path() -> Self { + ProjectStore { + base_path: PathBuf::from(".agent"), + } + } + + /// Get the base path + pub fn base_path(&self) -> &PathBuf { + &self.base_path + } + + /// Initialize the project store directory structure + /// + /// Creates the base directory and all resource subdirectories: + /// - templates/ + /// - standards/ + /// - specs/ + /// - steering/ + /// - boilerplates/ + /// - rules/ + /// - history/ + /// - cache/ + pub fn initialize(&self) -> StorageResult<()> { + // Create base directory + self.create_dir_if_not_exists(&self.base_path)?; + + // Create resource directories + for resource_type in &[ + ResourceType::Template, + ResourceType::Standard, + ResourceType::Spec, + ResourceType::Steering, + ResourceType::Boilerplate, + ResourceType::Rule, + ] { + let resource_path = self.resource_path(*resource_type); + self.create_dir_if_not_exists(&resource_path)?; + } + + // Create history directory + let history_path = self.base_path.join("history"); + self.create_dir_if_not_exists(&history_path)?; + + // Create cache directory + let cache_path = self.base_path.join("cache"); + self.create_dir_if_not_exists(&cache_path)?; + + Ok(()) + } + + /// Get the path for a resource type + pub fn resource_path(&self, resource_type: ResourceType) -> PathBuf { + self.base_path.join(resource_type.dir_name()) + } + + /// Store a resource file + pub fn store_resource( + &self, + resource_type: ResourceType, + name: &str, + content: &[u8], + ) -> StorageResult { + let resource_dir = self.resource_path(resource_type); + let file_path = resource_dir.join(name); + + // Ensure directory exists + self.create_dir_if_not_exists(&resource_dir)?; + + // Write file + fs::write(&file_path, content).map_err(|e| { + StorageError::io_error(file_path.clone(), IoOperation::Write, e) + })?; + + Ok(file_path) + } + + /// Retrieve a resource file + pub fn retrieve_resource( + &self, + resource_type: ResourceType, + name: &str, + ) -> StorageResult> { + let resource_dir = self.resource_path(resource_type); + let file_path = resource_dir.join(name); + + fs::read(&file_path).map_err(|e| { + StorageError::io_error(file_path, IoOperation::Read, e) + }) + } + + /// List all resources of a type + pub fn list_resources(&self, resource_type: ResourceType) -> StorageResult> { + let resource_dir = self.resource_path(resource_type); + + if !resource_dir.exists() { + return Ok(Vec::new()); + } + + let mut resources = Vec::new(); + let entries = fs::read_dir(&resource_dir).map_err(|e| { + StorageError::io_error(resource_dir.clone(), IoOperation::Read, e) + })?; + + for entry in entries { + let entry = entry.map_err(|e| { + StorageError::io_error(resource_dir.clone(), IoOperation::Read, e) + })?; + + let path = entry.path(); + if path.is_file() { + if let Some(file_name) = path.file_name() { + if let Some(name_str) = file_name.to_str() { + resources.push(name_str.to_string()); + } + } + } + } + + Ok(resources) + } + + /// Delete a resource file + pub fn delete_resource( + &self, + resource_type: ResourceType, + name: &str, + ) -> StorageResult<()> { + let resource_dir = self.resource_path(resource_type); + let file_path = resource_dir.join(name); + + if file_path.exists() { + fs::remove_file(&file_path).map_err(|e| { + StorageError::io_error(file_path, IoOperation::Delete, e) + })?; + } + + Ok(()) + } + + /// Check if a resource exists + pub fn resource_exists(&self, resource_type: ResourceType, name: &str) -> bool { + let resource_dir = self.resource_path(resource_type); + let file_path = resource_dir.join(name); + file_path.exists() + } + + /// Create a folder on-demand + /// + /// Creates a folder in the project store if it doesn't exist. + /// This allows projects to create custom folders as needed. + pub fn create_folder(&self, folder_name: &str) -> StorageResult { + let folder_path = self.base_path.join(folder_name); + self.create_dir_if_not_exists(&folder_path)?; + Ok(folder_path) + } + + /// Check if a folder exists + pub fn folder_exists(&self, folder_name: &str) -> bool { + let folder_path = self.base_path.join(folder_name); + folder_path.is_dir() + } + + /// Create a directory if it doesn't exist + fn create_dir_if_not_exists(&self, path: &Path) -> StorageResult<()> { + if !path.exists() { + fs::create_dir_all(path).map_err(|e| { + StorageError::directory_creation_failed(path.to_path_buf(), e) + })?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_project_store_initialization() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = ProjectStore::new(temp_dir.path().to_path_buf()); + + store.initialize().expect("Failed to initialize store"); + + // Verify all directories were created + assert!(store.resource_path(ResourceType::Template).exists()); + assert!(store.resource_path(ResourceType::Standard).exists()); + assert!(store.resource_path(ResourceType::Spec).exists()); + assert!(store.resource_path(ResourceType::Steering).exists()); + assert!(store.resource_path(ResourceType::Boilerplate).exists()); + assert!(store.resource_path(ResourceType::Rule).exists()); + assert!(temp_dir.path().join("history").exists()); + assert!(temp_dir.path().join("cache").exists()); + } + + #[test] + fn test_store_and_retrieve_resource() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = ProjectStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + let content = b"test content"; + let name = "test.txt"; + + // Store resource + let path = store + .store_resource(ResourceType::Template, name, content) + .expect("Failed to store resource"); + + assert!(path.exists()); + + // Retrieve resource + let retrieved = store + .retrieve_resource(ResourceType::Template, name) + .expect("Failed to retrieve resource"); + + assert_eq!(retrieved, content); + } + + #[test] + fn test_create_folder_on_demand() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = ProjectStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + let folder_name = "custom_folder"; + assert!(!store.folder_exists(folder_name)); + + let folder_path = store + .create_folder(folder_name) + .expect("Failed to create folder"); + + assert!(folder_path.exists()); + assert!(store.folder_exists(folder_name)); + } + + #[test] + fn test_list_resources() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = ProjectStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + // Store multiple resources + store + .store_resource(ResourceType::Template, "template1.txt", b"content1") + .expect("Failed to store"); + store + .store_resource(ResourceType::Template, "template2.txt", b"content2") + .expect("Failed to store"); + + // List resources + let resources = store + .list_resources(ResourceType::Template) + .expect("Failed to list resources"); + + assert_eq!(resources.len(), 2); + assert!(resources.contains(&"template1.txt".to_string())); + assert!(resources.contains(&"template2.txt".to_string())); + } + + #[test] + fn test_delete_resource() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = ProjectStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + let name = "test.txt"; + store + .store_resource(ResourceType::Template, name, b"content") + .expect("Failed to store"); + + assert!(store.resource_exists(ResourceType::Template, name)); + + store + .delete_resource(ResourceType::Template, name) + .expect("Failed to delete"); + + assert!(!store.resource_exists(ResourceType::Template, name)); + } + + #[test] + fn test_resource_exists() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = ProjectStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + let name = "test.txt"; + assert!(!store.resource_exists(ResourceType::Template, name)); + + store + .store_resource(ResourceType::Template, name, b"content") + .expect("Failed to store"); + + assert!(store.resource_exists(ResourceType::Template, name)); + } +} diff --git a/crates/ricecoder-storage/src/types.rs b/crates/ricecoder-storage/src/types.rs new file mode 100644 index 00000000..e4e38960 --- /dev/null +++ b/crates/ricecoder-storage/src/types.rs @@ -0,0 +1,178 @@ +//! Core types for RiceCoder storage + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Storage configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageConfig { + /// Path to global storage directory + pub global_path: PathBuf, + /// Path to project storage directory (if in a project) + pub project_path: Option, + /// Storage mode (how to combine global and project storage) + pub mode: StorageMode, + /// Whether this is the first initialization + pub first_run: bool, +} + +/// Storage mode determines how global and project storage are combined +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum StorageMode { + /// Only use global storage + GlobalOnly, + /// Only use project storage + ProjectOnly, + /// Merge both, with project overriding global + Merged, +} + +/// Resource types that can be stored +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ResourceType { + /// Templates for code generation + Template, + /// Coding standards and guidelines + Standard, + /// Specification documents + Spec, + /// Steering documents (project rules) + Steering, + /// Boilerplate projects + Boilerplate, + /// Learned rules from the learning system + Rule, +} + +impl ResourceType { + /// Get the directory name for this resource type + pub fn dir_name(&self) -> &'static str { + match self { + ResourceType::Template => "templates", + ResourceType::Standard => "standards", + ResourceType::Spec => "specs", + ResourceType::Steering => "steering", + ResourceType::Boilerplate => "boilerplates", + ResourceType::Rule => "rules", + } + } +} + +/// Configuration file format +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ConfigFormat { + /// YAML format (.yaml, .yml) + Yaml, + /// TOML format (.toml) + Toml, + /// JSON format (.json) + Json, +} + +impl ConfigFormat { + /// Get the file extension for this format + pub fn extension(&self) -> &'static str { + match self { + ConfigFormat::Yaml => "yaml", + ConfigFormat::Toml => "toml", + ConfigFormat::Json => "json", + } + } + + /// Detect format from file extension + pub fn from_extension(ext: &str) -> Option { + match ext.to_lowercase().as_str() { + "yaml" | "yml" => Some(ConfigFormat::Yaml), + "toml" => Some(ConfigFormat::Toml), + "json" => Some(ConfigFormat::Json), + _ => None, + } + } +} + +/// Document format for steering and specs +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DocumentFormat { + /// YAML format + Yaml, + /// Markdown format + Markdown, +} + +impl DocumentFormat { + /// Get the file extension for this format + pub fn extension(&self) -> &'static str { + match self { + DocumentFormat::Yaml => "yaml", + DocumentFormat::Markdown => "md", + } + } + + /// Detect format from file extension + pub fn from_extension(ext: &str) -> Option { + match ext.to_lowercase().as_str() { + "yaml" | "yml" => Some(DocumentFormat::Yaml), + "md" | "markdown" => Some(DocumentFormat::Markdown), + _ => None, + } + } +} + +/// Storage availability state +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StorageState { + /// Storage is available and writable + Available, + /// Storage is unavailable (e.g., external drive disconnected) + Unavailable { reason: String }, + /// Storage is available but read-only (e.g., offline mode) + ReadOnly { cached_at: String }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resource_type_dir_names() { + assert_eq!(ResourceType::Template.dir_name(), "templates"); + assert_eq!(ResourceType::Standard.dir_name(), "standards"); + assert_eq!(ResourceType::Spec.dir_name(), "specs"); + assert_eq!(ResourceType::Steering.dir_name(), "steering"); + assert_eq!(ResourceType::Boilerplate.dir_name(), "boilerplates"); + assert_eq!(ResourceType::Rule.dir_name(), "rules"); + } + + #[test] + fn test_config_format_extensions() { + assert_eq!(ConfigFormat::Yaml.extension(), "yaml"); + assert_eq!(ConfigFormat::Toml.extension(), "toml"); + assert_eq!(ConfigFormat::Json.extension(), "json"); + } + + #[test] + fn test_config_format_detection() { + assert_eq!(ConfigFormat::from_extension("yaml"), Some(ConfigFormat::Yaml)); + assert_eq!(ConfigFormat::from_extension("yml"), Some(ConfigFormat::Yaml)); + assert_eq!(ConfigFormat::from_extension("toml"), Some(ConfigFormat::Toml)); + assert_eq!(ConfigFormat::from_extension("json"), Some(ConfigFormat::Json)); + assert_eq!(ConfigFormat::from_extension("txt"), None); + } + + #[test] + fn test_document_format_detection() { + assert_eq!( + DocumentFormat::from_extension("yaml"), + Some(DocumentFormat::Yaml) + ); + assert_eq!( + DocumentFormat::from_extension("md"), + Some(DocumentFormat::Markdown) + ); + assert_eq!( + DocumentFormat::from_extension("markdown"), + Some(DocumentFormat::Markdown) + ); + assert_eq!(DocumentFormat::from_extension("txt"), None); + } +} diff --git a/crates/ricecoder-storage/tests/property_first_run_confirmation.rs b/crates/ricecoder-storage/tests/property_first_run_confirmation.rs new file mode 100644 index 00000000..e16c4b54 --- /dev/null +++ b/crates/ricecoder-storage/tests/property_first_run_confirmation.rs @@ -0,0 +1,140 @@ +//! Property-based tests for first-run confirmation +//! **Feature: ricecoder-storage, Property 14: First-Run Storage Confirmation** +//! **Validates: Requirements 6.4** + +use proptest::prelude::*; +use ricecoder_storage::FirstRunHandler; +use tempfile::TempDir; + +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property 14: First-Run Storage Confirmation + /// For any first-time initialization, the FirstRunHandler should correctly + /// detect that it's the first run before the marker file is created, + /// and should correctly detect that it's not the first run after the marker is created. + #[test] + fn prop_first_run_detection(_dummy in Just(())) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let path = temp_dir.path().to_path_buf(); + + // Initially should be first run (no marker file) + let is_first_before = FirstRunHandler::is_first_run(&path) + .expect("Failed to check first run"); + assert!( + is_first_before, + "Should detect first run when marker doesn't exist" + ); + + // Mark as complete + FirstRunHandler::mark_first_run_complete(&path) + .expect("Failed to mark first run complete"); + + // Should no longer be first run + let is_first_after = FirstRunHandler::is_first_run(&path) + .expect("Failed to check first run"); + assert!( + !is_first_after, + "Should not detect first run after marker is created" + ); + } + + /// Property: First-run marker file is created correctly + /// After marking first run as complete, the marker file should exist + #[test] + fn prop_first_run_marker_creation(_dummy in Just(())) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let path = temp_dir.path().to_path_buf(); + + // Mark as complete + FirstRunHandler::mark_first_run_complete(&path) + .expect("Failed to mark first run complete"); + + // Verify marker file exists + let marker_path = path.join(".ricecoder-initialized"); + assert!( + marker_path.exists(), + "Marker file should exist after marking first run complete" + ); + } + + /// Property: First-run detection is idempotent + /// Checking first-run status multiple times should always return the same result + #[test] + fn prop_first_run_detection_idempotent(_dummy in Just(())) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let path = temp_dir.path().to_path_buf(); + + // Check multiple times before marking + let check1 = FirstRunHandler::is_first_run(&path) + .expect("First check failed"); + let check2 = FirstRunHandler::is_first_run(&path) + .expect("Second check failed"); + let check3 = FirstRunHandler::is_first_run(&path) + .expect("Third check failed"); + + assert_eq!(check1, check2, "First and second checks should match"); + assert_eq!(check2, check3, "Second and third checks should match"); + + // Mark as complete + FirstRunHandler::mark_first_run_complete(&path) + .expect("Failed to mark first run complete"); + + // Check multiple times after marking + let check4 = FirstRunHandler::is_first_run(&path) + .expect("Fourth check failed"); + let check5 = FirstRunHandler::is_first_run(&path) + .expect("Fifth check failed"); + let check6 = FirstRunHandler::is_first_run(&path) + .expect("Sixth check failed"); + + assert_eq!(check4, check5, "Fourth and fifth checks should match"); + assert_eq!(check5, check6, "Fifth and sixth checks should match"); + + // All after-marking checks should be false + assert!(!check4, "After marking, should not be first run"); + assert!(!check5, "After marking, should not be first run"); + assert!(!check6, "After marking, should not be first run"); + } + + /// Property: Suggested path is always valid + /// The suggested path should always be resolvable and contain .ricecoder + #[test] + fn prop_suggested_path_valid(_dummy in Just(())) { + let path = FirstRunHandler::get_suggested_path() + .expect("Failed to get suggested path"); + + // Path should contain .ricecoder + assert!( + path.to_string_lossy().contains(".ricecoder"), + "Suggested path should contain .ricecoder" + ); + + // Path should be absolute or relative (not empty) + assert!( + !path.as_os_str().is_empty(), + "Suggested path should not be empty" + ); + } + + /// Property: Multiple first-run completions are safe + /// Marking first run as complete multiple times should not cause errors + #[test] + fn prop_multiple_first_run_completions(_dummy in Just(())) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let path = temp_dir.path().to_path_buf(); + + // Mark as complete multiple times + FirstRunHandler::mark_first_run_complete(&path) + .expect("First mark failed"); + FirstRunHandler::mark_first_run_complete(&path) + .expect("Second mark failed"); + FirstRunHandler::mark_first_run_complete(&path) + .expect("Third mark failed"); + + // Should still not be first run + let is_first = FirstRunHandler::is_first_run(&path) + .expect("Failed to check first run"); + assert!(!is_first, "Should not be first run after multiple marks"); + } +} diff --git a/crates/ricecoder-storage/tests/property_global_resource_storage.rs b/crates/ricecoder-storage/tests/property_global_resource_storage.rs new file mode 100644 index 00000000..2e5fde29 --- /dev/null +++ b/crates/ricecoder-storage/tests/property_global_resource_storage.rs @@ -0,0 +1,192 @@ +//! Property-based tests for global resource storage +//! **Feature: ricecoder-storage, Property 1: Global Resource Storage Consistency** +//! **Validates: Requirements 1.2, 1.3, 1.4, 1.5** + +use proptest::prelude::*; +use ricecoder_storage::{GlobalStore, ResourceType}; +use tempfile::TempDir; + +/// Strategy for generating valid resource names +fn resource_name_strategy() -> impl Strategy { + r"[a-zA-Z0-9_\-]+" // Alphanumeric, underscore, dash + .prop_map(|s| format!("{}.txt", s)) + .prop_filter("Name should be non-empty", |s| !s.is_empty()) +} + +/// Strategy for generating valid resource content +fn resource_content_strategy() -> impl Strategy> { + prop::collection::vec(any::(), 0..1000) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property 1: Global Resource Storage Consistency + /// For any resource type (template, standard, spec, steering) and any valid resource content, + /// storing the resource globally should persist it to the correct subdirectory + /// and the resource should be retrievable with identical content. + #[test] + fn prop_global_resource_storage_consistency( + resource_type in prop_oneof![ + Just(ResourceType::Template), + Just(ResourceType::Standard), + Just(ResourceType::Spec), + Just(ResourceType::Steering), + ], + name in resource_name_strategy(), + content in resource_content_strategy(), + ) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = GlobalStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + // Store the resource + let stored_path = store + .store_resource(resource_type, &name, &content) + .expect("Failed to store resource"); + + // Verify the file was created in the correct directory + assert!(stored_path.exists(), "Stored file should exist"); + assert_eq!( + stored_path.parent().unwrap(), + store.resource_path(resource_type), + "File should be in correct resource directory" + ); + + // Retrieve the resource + let retrieved = store + .retrieve_resource(resource_type, &name) + .expect("Failed to retrieve resource"); + + // Verify the content is identical + assert_eq!( + retrieved, content, + "Retrieved content should match stored content" + ); + } + + /// Property: Resource storage is idempotent + /// Storing the same resource multiple times should result in the same content + #[test] + fn prop_resource_storage_idempotent( + resource_type in prop_oneof![ + Just(ResourceType::Template), + Just(ResourceType::Standard), + ], + name in resource_name_strategy(), + content in resource_content_strategy(), + ) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = GlobalStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + // Store the resource multiple times + store + .store_resource(resource_type, &name, &content) + .expect("First store failed"); + store + .store_resource(resource_type, &name, &content) + .expect("Second store failed"); + store + .store_resource(resource_type, &name, &content) + .expect("Third store failed"); + + // Retrieve and verify + let retrieved = store + .retrieve_resource(resource_type, &name) + .expect("Failed to retrieve resource"); + + assert_eq!( + retrieved, content, + "Content should be identical after multiple stores" + ); + } + + /// Property: Resource existence check is accurate + /// After storing a resource, it should exist; after deleting it, it should not exist + #[test] + fn prop_resource_existence_accurate( + resource_type in prop_oneof![ + Just(ResourceType::Template), + Just(ResourceType::Standard), + ], + name in resource_name_strategy(), + content in resource_content_strategy(), + ) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = GlobalStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + // Initially should not exist + assert!( + !store.resource_exists(resource_type, &name), + "Resource should not exist initially" + ); + + // Store the resource + store + .store_resource(resource_type, &name, &content) + .expect("Failed to store resource"); + + // Now should exist + assert!( + store.resource_exists(resource_type, &name), + "Resource should exist after storing" + ); + + // Delete the resource + store + .delete_resource(resource_type, &name) + .expect("Failed to delete resource"); + + // Should not exist anymore + assert!( + !store.resource_exists(resource_type, &name), + "Resource should not exist after deletion" + ); + } + + /// Property: List resources returns all stored resources + /// After storing multiple resources, listing should return all of them + #[test] + fn prop_list_resources_complete( + resource_type in prop_oneof![ + Just(ResourceType::Template), + Just(ResourceType::Standard), + ], + names in prop::collection::vec(resource_name_strategy(), 1..10), + content in resource_content_strategy(), + ) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = GlobalStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + // Store all resources + for name in &names { + store + .store_resource(resource_type, name, &content) + .expect("Failed to store resource"); + } + + // List resources + let listed = store + .list_resources(resource_type) + .expect("Failed to list resources"); + + // Verify all stored resources are in the list + for name in &names { + assert!( + listed.contains(name), + "Stored resource {} should be in list", + name + ); + } + + // Verify count matches + assert_eq!( + listed.len(), + names.len(), + "Listed resources count should match stored count" + ); + } +} diff --git a/crates/ricecoder-storage/tests/property_project_storage.rs b/crates/ricecoder-storage/tests/property_project_storage.rs new file mode 100644 index 00000000..4a576632 --- /dev/null +++ b/crates/ricecoder-storage/tests/property_project_storage.rs @@ -0,0 +1,216 @@ +//! Property-based tests for project storage +//! **Feature: ricecoder-storage, Property 2: Project Resource Storage Consistency** +//! **Feature: ricecoder-storage, Property 12: Folder Creation On-Demand** +//! **Validates: Requirements 2.2, 2.5, 2.6** + +use proptest::prelude::*; +use ricecoder_storage::{ProjectStore, ResourceType}; +use tempfile::TempDir; + +/// Strategy for generating valid resource names +fn resource_name_strategy() -> impl Strategy { + r"[a-zA-Z0-9_\-]+" // Alphanumeric, underscore, dash + .prop_map(|s| format!("{}.txt", s)) + .prop_filter("Name should be non-empty", |s| !s.is_empty()) +} + +/// Strategy for generating valid folder names +fn folder_name_strategy() -> impl Strategy { + r"[a-zA-Z0-9_\-]+" // Alphanumeric, underscore, dash + .prop_filter("Name should be non-empty", |s| !s.is_empty()) +} + +/// Strategy for generating valid resource content +fn resource_content_strategy() -> impl Strategy> { + prop::collection::vec(any::(), 0..1000) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property 2: Project Resource Storage Consistency + /// For any project configuration or spec, storing it in the project store should persist it + /// to the correct location (./.agent/specs/ or ./.agent/config.yaml) and the resource + /// should be retrievable with identical content. + #[test] + fn prop_project_resource_storage_consistency( + resource_type in prop_oneof![ + Just(ResourceType::Template), + Just(ResourceType::Standard), + Just(ResourceType::Spec), + Just(ResourceType::Steering), + ], + name in resource_name_strategy(), + content in resource_content_strategy(), + ) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = ProjectStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + // Store the resource + let stored_path = store + .store_resource(resource_type, &name, &content) + .expect("Failed to store resource"); + + // Verify the file was created in the correct directory + assert!(stored_path.exists(), "Stored file should exist"); + assert_eq!( + stored_path.parent().unwrap(), + store.resource_path(resource_type), + "File should be in correct resource directory" + ); + + // Retrieve the resource + let retrieved = store + .retrieve_resource(resource_type, &name) + .expect("Failed to retrieve resource"); + + // Verify the content is identical + assert_eq!( + retrieved, content, + "Retrieved content should match stored content" + ); + } + + /// Property 12: Folder Creation On-Demand + /// For any valid folder name, creating the folder should result in a directory + /// that exists and can be verified with folder_exists() + #[test] + fn prop_folder_creation_on_demand(folder_name in folder_name_strategy()) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = ProjectStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + // Initially should not exist + assert!( + !store.folder_exists(&folder_name), + "Folder should not exist initially" + ); + + // Create the folder + let folder_path = store + .create_folder(&folder_name) + .expect("Failed to create folder"); + + // Verify the folder was created + assert!(folder_path.exists(), "Folder should exist after creation"); + assert!( + store.folder_exists(&folder_name), + "folder_exists should return true after creation" + ); + + // Verify it's a directory + assert!( + folder_path.is_dir(), + "Created path should be a directory" + ); + } + + /// Property: Folder creation is idempotent + /// Creating the same folder multiple times should not cause errors + #[test] + fn prop_folder_creation_idempotent(folder_name in folder_name_strategy()) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = ProjectStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + // Create the folder multiple times + let path1 = store + .create_folder(&folder_name) + .expect("First create failed"); + let path2 = store + .create_folder(&folder_name) + .expect("Second create failed"); + let path3 = store + .create_folder(&folder_name) + .expect("Third create failed"); + + // All paths should be identical + assert_eq!(path1, path2, "First and second paths should match"); + assert_eq!(path2, path3, "Second and third paths should match"); + + // Folder should still exist + assert!(store.folder_exists(&folder_name)); + } + + /// Property: Project resource storage is idempotent + /// Storing the same resource multiple times should result in the same content + #[test] + fn prop_project_resource_storage_idempotent( + resource_type in prop_oneof![ + Just(ResourceType::Template), + Just(ResourceType::Standard), + ], + name in resource_name_strategy(), + content in resource_content_strategy(), + ) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = ProjectStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + // Store the resource multiple times + store + .store_resource(resource_type, &name, &content) + .expect("First store failed"); + store + .store_resource(resource_type, &name, &content) + .expect("Second store failed"); + store + .store_resource(resource_type, &name, &content) + .expect("Third store failed"); + + // Retrieve and verify + let retrieved = store + .retrieve_resource(resource_type, &name) + .expect("Failed to retrieve resource"); + + assert_eq!( + retrieved, content, + "Content should be identical after multiple stores" + ); + } + + /// Property: Resource existence check is accurate + /// After storing a resource, it should exist; after deleting it, it should not exist + #[test] + fn prop_project_resource_existence_accurate( + resource_type in prop_oneof![ + Just(ResourceType::Template), + Just(ResourceType::Standard), + ], + name in resource_name_strategy(), + content in resource_content_strategy(), + ) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let store = ProjectStore::new(temp_dir.path().to_path_buf()); + store.initialize().expect("Failed to initialize store"); + + // Initially should not exist + assert!( + !store.resource_exists(resource_type, &name), + "Resource should not exist initially" + ); + + // Store the resource + store + .store_resource(resource_type, &name, &content) + .expect("Failed to store resource"); + + // Now should exist + assert!( + store.resource_exists(resource_type, &name), + "Resource should exist after storing" + ); + + // Delete the resource + store + .delete_resource(resource_type, &name) + .expect("Failed to delete resource"); + + // Should not exist anymore + assert!( + !store.resource_exists(resource_type, &name), + "Resource should not exist after deletion" + ); + } +} diff --git a/crates/ricecoder-storage/tests/property_ricecoder_home_override.proptest-regressions b/crates/ricecoder-storage/tests/property_ricecoder_home_override.proptest-regressions new file mode 100644 index 00000000..e8adae92 --- /dev/null +++ b/crates/ricecoder-storage/tests/property_ricecoder_home_override.proptest-regressions @@ -0,0 +1,9 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc c555c39242bd67b32d4d492212aad36f0b860d2a8defd82304fa0bc3552e195c # shrinks to path = "test_\\tHN\\Rx_\\\\vP__/h\\jh" +cc 10ada06078bac6804fc30e3b921db95708b50c7932e6c1eadd2a2764494cb42a # shrinks to _dummy = () +cc b9e532bd1979f3f41e4bbbb6c9705095ca445551cb8a2391c9415bf1c3d24534 # shrinks to path = "9W_f\\_.ZL_\\Zjj-_" diff --git a/crates/ricecoder-storage/tests/property_ricecoder_home_override.rs b/crates/ricecoder-storage/tests/property_ricecoder_home_override.rs new file mode 100644 index 00000000..b8786cb3 --- /dev/null +++ b/crates/ricecoder-storage/tests/property_ricecoder_home_override.rs @@ -0,0 +1,87 @@ +//! Property-based tests for RICECODER_HOME override +//! **Feature: ricecoder-storage, Property 10: RICECODER_HOME Override** +//! **Validates: Requirements 6.2** + +use proptest::prelude::*; +use ricecoder_storage::PathResolver; +use std::path::PathBuf; +use std::sync::Mutex; + +// Mutex to serialize environment variable access in tests +lazy_static::lazy_static! { + static ref ENV_LOCK: Mutex<()> = Mutex::new(()); +} + +/// Strategy for generating valid directory paths +/// Generates paths that are valid on both Windows and Unix systems +fn valid_path_strategy() -> impl Strategy { + // Generate simple alphanumeric paths to avoid special character issues + r"[a-zA-Z0-9_\-]+" // Only alphanumeric, underscore, dash + .prop_map(|s| format!("/tmp/ricecoder_{}", s)) + .prop_filter("Path should be non-empty", |s| !s.is_empty()) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property 10: RICECODER_HOME Override + /// For any valid directory path set in the RICECODER_HOME environment variable, + /// the PathResolver should use that path as the global storage location + /// instead of the default. + #[test] + fn prop_ricecoder_home_override(path in valid_path_strategy()) { + // Lock to prevent parallel test interference + let _lock = ENV_LOCK.lock().unwrap(); + + // Set RICECODER_HOME to the generated path + std::env::set_var("RICECODER_HOME", &path); + + // Resolve the global path + let resolved = PathResolver::resolve_global_path() + .expect("Should resolve path with RICECODER_HOME set"); + + // Verify the resolved path matches the environment variable + assert_eq!( + resolved, + PathBuf::from(&path), + "Resolved path should match RICECODER_HOME environment variable" + ); + + // Clean up + std::env::remove_var("RICECODER_HOME"); + } + + /// Property: RICECODER_HOME takes precedence over default paths + /// When RICECODER_HOME is set, it should always be used regardless of + /// whether Documents folder exists + #[test] + fn prop_ricecoder_home_precedence(path in valid_path_strategy()) { + // Lock to prevent parallel test interference + let _lock = ENV_LOCK.lock().unwrap(); + + // Set RICECODER_HOME + std::env::set_var("RICECODER_HOME", &path); + + // Resolve path multiple times to ensure consistency + let resolved1 = PathResolver::resolve_global_path() + .expect("First resolution should succeed"); + let resolved2 = PathResolver::resolve_global_path() + .expect("Second resolution should succeed"); + + // Both resolutions should be identical + assert_eq!( + resolved1, resolved2, + "Multiple resolutions should produce identical results" + ); + + // Both should match the environment variable + assert_eq!( + resolved1, + PathBuf::from(&path), + "Should use RICECODER_HOME value" + ); + + // Clean up + std::env::remove_var("RICECODER_HOME"); + } +} From 6c886917e380a2341b1702e32a67930fa6fa5e02 Mon Sep 17 00:00:00 2001 From: Mo Abualruz Date: Mon, 1 Dec 2025 13:09:45 +0100 Subject: [PATCH 2/2] Add property-based tests for configuration and storage features - Implemented property tests for environment variable overrides in `property_env_override.rs`. - Added regression seeds for previously failing cases in `property_industry_file_merge_precedence.proptest-regressions`. - Created property tests for industry file merge precedence in `property_industry_file_merge_precedence.rs`. - Developed property tests for offline mode functionality in `property_offline_mode.rs`. - Added regression seeds for relocation round-trip failures in `property_relocation_roundtrip.proptest-regressions`. - Implemented property tests for storage relocation in `property_relocation_roundtrip.rs`. - Created property tests for storage modes and their behaviors in `property_storage_modes.rs`. --- crates/ricecoder-storage/src/cache/manager.rs | 487 ++++++++++++++++++ crates/ricecoder-storage/src/cache/mod.rs | 8 + .../ricecoder-storage/src/config/documents.rs | 192 +++++++ crates/ricecoder-storage/src/config/env.rs | 195 +++++++ crates/ricecoder-storage/src/config/loader.rs | 271 ++++++++++ crates/ricecoder-storage/src/config/merge.rs | 254 +++++++++ crates/ricecoder-storage/src/config/mod.rs | 102 ++++ crates/ricecoder-storage/src/config/modes.rs | 279 ++++++++++ crates/ricecoder-storage/src/first_run.rs | 10 +- .../ricecoder-storage/src/industry/adapter.rs | 209 ++++++++ .../ricecoder-storage/src/industry/agents.rs | 132 +++++ .../ricecoder-storage/src/industry/aider.rs | 132 +++++ .../ricecoder-storage/src/industry/claude.rs | 132 +++++ .../ricecoder-storage/src/industry/cline.rs | 132 +++++ .../src/industry/continue_dev.rs | 178 +++++++ .../ricecoder-storage/src/industry/copilot.rs | 139 +++++ .../ricecoder-storage/src/industry/cursor.rs | 245 +++++++++ crates/ricecoder-storage/src/industry/kiro.rs | 190 +++++++ crates/ricecoder-storage/src/industry/mod.rs | 42 ++ .../src/industry/windsurf.rs | 132 +++++ crates/ricecoder-storage/src/lib.rs | 10 + crates/ricecoder-storage/src/manager.rs | 6 +- crates/ricecoder-storage/src/offline.rs | 304 +++++++++++ crates/ricecoder-storage/src/relocation.rs | 304 +++++++++++ .../tests/property_config_format_roundtrip.rs | 111 ++++ ...property_config_merge.proptest-regressions | 7 + .../tests/property_config_merge.rs | 208 ++++++++ .../tests/property_document_format_support.rs | 164 ++++++ .../tests/property_env_override.rs | 157 ++++++ ...file_merge_precedence.proptest-regressions | 8 + ...property_industry_file_merge_precedence.rs | 320 ++++++++++++ .../tests/property_offline_mode.rs | 202 ++++++++ ..._relocation_roundtrip.proptest-regressions | 7 + .../tests/property_relocation_roundtrip.rs | 221 ++++++++ .../tests/property_storage_modes.rs | 192 +++++++ 35 files changed, 5674 insertions(+), 8 deletions(-) create mode 100644 crates/ricecoder-storage/src/cache/manager.rs create mode 100644 crates/ricecoder-storage/src/cache/mod.rs create mode 100644 crates/ricecoder-storage/src/config/documents.rs create mode 100644 crates/ricecoder-storage/src/config/env.rs create mode 100644 crates/ricecoder-storage/src/config/loader.rs create mode 100644 crates/ricecoder-storage/src/config/merge.rs create mode 100644 crates/ricecoder-storage/src/config/mod.rs create mode 100644 crates/ricecoder-storage/src/config/modes.rs create mode 100644 crates/ricecoder-storage/src/industry/adapter.rs create mode 100644 crates/ricecoder-storage/src/industry/agents.rs create mode 100644 crates/ricecoder-storage/src/industry/aider.rs create mode 100644 crates/ricecoder-storage/src/industry/claude.rs create mode 100644 crates/ricecoder-storage/src/industry/cline.rs create mode 100644 crates/ricecoder-storage/src/industry/continue_dev.rs create mode 100644 crates/ricecoder-storage/src/industry/copilot.rs create mode 100644 crates/ricecoder-storage/src/industry/cursor.rs create mode 100644 crates/ricecoder-storage/src/industry/kiro.rs create mode 100644 crates/ricecoder-storage/src/industry/mod.rs create mode 100644 crates/ricecoder-storage/src/industry/windsurf.rs create mode 100644 crates/ricecoder-storage/src/offline.rs create mode 100644 crates/ricecoder-storage/src/relocation.rs create mode 100644 crates/ricecoder-storage/tests/property_config_format_roundtrip.rs create mode 100644 crates/ricecoder-storage/tests/property_config_merge.proptest-regressions create mode 100644 crates/ricecoder-storage/tests/property_config_merge.rs create mode 100644 crates/ricecoder-storage/tests/property_document_format_support.rs create mode 100644 crates/ricecoder-storage/tests/property_env_override.rs create mode 100644 crates/ricecoder-storage/tests/property_industry_file_merge_precedence.proptest-regressions create mode 100644 crates/ricecoder-storage/tests/property_industry_file_merge_precedence.rs create mode 100644 crates/ricecoder-storage/tests/property_offline_mode.rs create mode 100644 crates/ricecoder-storage/tests/property_relocation_roundtrip.proptest-regressions create mode 100644 crates/ricecoder-storage/tests/property_relocation_roundtrip.rs create mode 100644 crates/ricecoder-storage/tests/property_storage_modes.rs diff --git a/crates/ricecoder-storage/src/cache/manager.rs b/crates/ricecoder-storage/src/cache/manager.rs new file mode 100644 index 00000000..f28b2a00 --- /dev/null +++ b/crates/ricecoder-storage/src/cache/manager.rs @@ -0,0 +1,487 @@ +//! Cache manager implementation for RiceCoder storage +//! +//! Provides file-based cache storage with TTL and manual invalidation strategies. +//! Adapted from automation/src/infrastructure/cache/cache_manager.rs + +use crate::error::{IoOperation, StorageError, StorageResult}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tracing::{debug, warn}; + +/// Cache invalidation strategy +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CacheInvalidationStrategy { + /// Time-to-live: cache expires after specified duration (in seconds) + #[serde(rename = "ttl")] + Ttl(u64), + /// Manual: cache must be explicitly invalidated + #[serde(rename = "manual")] + Manual, +} + + + +/// Cache entry with metadata +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheEntry { + /// Cached data + pub data: String, + /// Timestamp when entry was created + pub created_at: u64, + /// Invalidation strategy + pub strategy: CacheInvalidationStrategy, +} + +impl CacheEntry { + /// Create a new cache entry + pub fn new(data: String, strategy: CacheInvalidationStrategy) -> Self { + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + Self { + data, + created_at, + strategy, + } + } + + /// Check if the entry has expired + pub fn is_expired(&self) -> bool { + match self.strategy { + CacheInvalidationStrategy::Ttl(ttl_secs) => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now > self.created_at + ttl_secs + } + CacheInvalidationStrategy::Manual => false, + } + } +} + +/// File-based cache manager +/// +/// Stores cache entries as JSON files in a cache directory. +/// Supports TTL and manual invalidation strategies. +pub struct CacheManager { + /// Cache directory path + cache_dir: PathBuf, +} + +impl CacheManager { + /// Create a new cache manager + /// + /// # Arguments + /// + /// * `cache_dir` - Directory to store cache files + /// + /// # Errors + /// + /// Returns error if cache directory cannot be created + pub fn new(cache_dir: impl AsRef) -> StorageResult { + let cache_dir = cache_dir.as_ref().to_path_buf(); + + // Create cache directory if it doesn't exist + if !cache_dir.exists() { + fs::create_dir_all(&cache_dir).map_err(|e| { + StorageError::directory_creation_failed(cache_dir.clone(), e) + })?; + debug!("Created cache directory: {}", cache_dir.display()); + } + + Ok(Self { cache_dir }) + } + + /// Get a cached value + /// + /// # Arguments + /// + /// * `key` - Cache key + /// + /// # Returns + /// + /// Returns the cached data if found and not expired, None if not found or expired + pub fn get(&self, key: &str) -> StorageResult> { + let path = self.key_to_path(key); + + if !path.exists() { + debug!("Cache miss for key: {}", key); + return Ok(None); + } + + let content = fs::read_to_string(&path).map_err(|e| { + StorageError::io_error(path.clone(), IoOperation::Read, e) + })?; + + let entry: CacheEntry = serde_json::from_str(&content).map_err(|e| { + StorageError::parse_error( + path.clone(), + "JSON", + format!("Failed to deserialize cache entry: {}", e), + ) + })?; + + if entry.is_expired() { + debug!("Cache expired for key: {}", key); + // Delete expired entry + let _ = fs::remove_file(&path); + return Ok(None); + } + + debug!("Cache hit for key: {}", key); + Ok(Some(entry.data)) + } + + /// Set a cached value + /// + /// # Arguments + /// + /// * `key` - Cache key + /// * `data` - Data to cache + /// * `strategy` - Invalidation strategy + /// + /// # Errors + /// + /// Returns error if cache entry cannot be written + pub fn set( + &self, + key: &str, + data: String, + strategy: CacheInvalidationStrategy, + ) -> StorageResult<()> { + let path = self.key_to_path(key); + + // Create parent directory if needed + if let Some(parent) = path.parent() { + if !parent.exists() { + fs::create_dir_all(parent).map_err(|e| { + StorageError::directory_creation_failed(parent.to_path_buf(), e) + })?; + } + } + + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let entry = CacheEntry { + data, + created_at, + strategy, + }; + + let json = serde_json::to_string_pretty(&entry).map_err(|e| { + StorageError::parse_error( + path.clone(), + "JSON", + format!("Failed to serialize cache entry: {}", e), + ) + })?; + + fs::write(&path, json).map_err(|e| { + StorageError::io_error(path.clone(), IoOperation::Write, e) + })?; + + debug!("Cached value for key: {}", key); + Ok(()) + } + + /// Invalidate a cached value + /// + /// # Arguments + /// + /// * `key` - Cache key to invalidate + /// + /// # Returns + /// + /// Returns Ok(true) if entry was deleted, Ok(false) if entry didn't exist + pub fn invalidate(&self, key: &str) -> StorageResult { + let path = self.key_to_path(key); + + if !path.exists() { + debug!("Cache entry not found for invalidation: {}", key); + return Ok(false); + } + + fs::remove_file(&path).map_err(|e| { + StorageError::io_error(path.clone(), IoOperation::Delete, e) + })?; + + debug!("Invalidated cache for key: {}", key); + Ok(true) + } + + /// Check if a key exists in cache and is not expired + /// + /// # Arguments + /// + /// * `key` - Cache key + pub fn exists(&self, key: &str) -> StorageResult { + let path = self.key_to_path(key); + + if !path.exists() { + return Ok(false); + } + + let content = fs::read_to_string(&path).map_err(|e| { + StorageError::io_error(path.clone(), IoOperation::Read, e) + })?; + + let entry: CacheEntry = serde_json::from_str(&content).map_err(|e| { + StorageError::parse_error( + path.clone(), + "JSON", + format!("Failed to deserialize cache entry: {}", e), + ) + })?; + + Ok(!entry.is_expired()) + } + + /// Clear all cache entries + /// + /// # Errors + /// + /// Returns error if cache directory cannot be cleared + pub fn clear(&self) -> StorageResult<()> { + if !self.cache_dir.exists() { + return Ok(()); + } + + fs::remove_dir_all(&self.cache_dir).map_err(|e| { + StorageError::io_error( + self.cache_dir.clone(), + IoOperation::Delete, + e, + ) + })?; + + fs::create_dir_all(&self.cache_dir).map_err(|e| { + StorageError::directory_creation_failed(self.cache_dir.clone(), e) + })?; + + debug!("Cleared all cache entries"); + Ok(()) + } + + /// Clean up expired entries + /// + /// Scans the cache directory and removes all expired entries. + /// + /// # Returns + /// + /// Returns the number of entries cleaned up + pub fn cleanup_expired(&self) -> StorageResult { + if !self.cache_dir.exists() { + return Ok(0); + } + + let mut cleaned = 0; + + for entry in fs::read_dir(&self.cache_dir).map_err(|e| { + StorageError::io_error( + self.cache_dir.clone(), + IoOperation::Read, + e, + ) + })? { + let entry = entry.map_err(|e| { + StorageError::io_error( + self.cache_dir.clone(), + IoOperation::Read, + e, + ) + })?; + + let path = entry.path(); + + if path.is_file() { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(cache_entry) = serde_json::from_str::(&content) { + if cache_entry.is_expired() { + if let Err(e) = fs::remove_file(&path) { + warn!("Failed to remove expired cache entry: {}", e); + } else { + cleaned += 1; + debug!("Cleaned up expired cache entry: {}", path.display()); + } + } + } + } + } + } + + debug!("Cleaned up {} expired cache entries", cleaned); + Ok(cleaned) + } + + /// Convert a cache key to a file path + fn key_to_path(&self, key: &str) -> PathBuf { + // Sanitize key to create valid filename + let sanitized = key + .chars() + .map(|c| if c.is_alphanumeric() || c == '_' || c == '-' { c } else { '_' }) + .collect::(); + + self.cache_dir.join(format!("{}.json", sanitized)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use tempfile::TempDir; + + #[test] + fn test_cache_set_and_get() -> StorageResult<()> { + let temp_dir = TempDir::new().unwrap(); + let cache = CacheManager::new(temp_dir.path())?; + + cache.set( + "test_key", + "test_data".to_string(), + CacheInvalidationStrategy::Manual, + )?; + + let result = cache.get("test_key")?; + assert_eq!(result, Some("test_data".to_string())); + + Ok(()) + } + + #[test] + fn test_cache_not_found() -> StorageResult<()> { + let temp_dir = TempDir::new().unwrap(); + let cache = CacheManager::new(temp_dir.path())?; + + let result = cache.get("nonexistent")?; + assert_eq!(result, None); + + Ok(()) + } + + #[test] + fn test_cache_invalidate() -> StorageResult<()> { + let temp_dir = TempDir::new().unwrap(); + let cache = CacheManager::new(temp_dir.path())?; + + cache.set( + "test_key", + "test_data".to_string(), + CacheInvalidationStrategy::Manual, + )?; + + let invalidated = cache.invalidate("test_key")?; + assert!(invalidated); + + let result = cache.get("test_key")?; + assert_eq!(result, None); + + Ok(()) + } + + #[test] + fn test_cache_exists() -> StorageResult<()> { + let temp_dir = TempDir::new().unwrap(); + let cache = CacheManager::new(temp_dir.path())?; + + cache.set( + "test_key", + "test_data".to_string(), + CacheInvalidationStrategy::Manual, + )?; + + assert!(cache.exists("test_key")?); + assert!(!cache.exists("nonexistent")?); + + Ok(()) + } + + #[test] + fn test_cache_clear() -> StorageResult<()> { + let temp_dir = TempDir::new().unwrap(); + let cache = CacheManager::new(temp_dir.path())?; + + cache.set( + "key1", + "data1".to_string(), + CacheInvalidationStrategy::Manual, + )?; + cache.set( + "key2", + "data2".to_string(), + CacheInvalidationStrategy::Manual, + )?; + + cache.clear()?; + + assert!(!cache.exists("key1")?); + assert!(!cache.exists("key2")?); + + Ok(()) + } + + #[test] + fn test_cache_ttl_expiration() -> StorageResult<()> { + let temp_dir = TempDir::new().unwrap(); + let cache = CacheManager::new(temp_dir.path())?; + + // Set cache with very short TTL (1 second) + cache.set( + "test_key", + "test_data".to_string(), + CacheInvalidationStrategy::Ttl(1), + )?; + + // Should exist immediately + assert!(cache.exists("test_key")?); + + // Wait for expiration + std::thread::sleep(Duration::from_secs(2)); + + // Should be expired now + let result = cache.get("test_key")?; + assert_eq!(result, None); + + Ok(()) + } + + #[test] + fn test_cache_cleanup_expired() -> StorageResult<()> { + let temp_dir = TempDir::new().unwrap(); + let cache = CacheManager::new(temp_dir.path())?; + + // Set cache with short TTL (1 second) + cache.set( + "expired_key", + "data".to_string(), + CacheInvalidationStrategy::Ttl(1), + )?; + + // Set cache with manual invalidation (won't expire) + cache.set( + "manual_key", + "data".to_string(), + CacheInvalidationStrategy::Manual, + )?; + + // Wait for first entry to expire + std::thread::sleep(Duration::from_secs(2)); + + // Cleanup should remove only expired entries + let cleaned = cache.cleanup_expired()?; + assert_eq!(cleaned, 1); + + // Manual entry should still exist + assert!(cache.exists("manual_key")?); + + Ok(()) + } +} diff --git a/crates/ricecoder-storage/src/cache/mod.rs b/crates/ricecoder-storage/src/cache/mod.rs new file mode 100644 index 00000000..5806f977 --- /dev/null +++ b/crates/ricecoder-storage/src/cache/mod.rs @@ -0,0 +1,8 @@ +//! Cache management for RiceCoder storage +//! +//! Provides caching abstractions and file-based cache storage for performance optimization. + +pub mod manager; + +// Re-export commonly used types +pub use manager::{CacheManager, CacheEntry, CacheInvalidationStrategy}; diff --git a/crates/ricecoder-storage/src/config/documents.rs b/crates/ricecoder-storage/src/config/documents.rs new file mode 100644 index 00000000..4cbfb097 --- /dev/null +++ b/crates/ricecoder-storage/src/config/documents.rs @@ -0,0 +1,192 @@ +//! Document format support for steering and specs +//! +//! This module provides loading and saving of documents in YAML and Markdown formats. + +use crate::error::{StorageError, StorageResult}; +use crate::types::DocumentFormat; +use std::path::Path; + +/// Document content +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Document { + /// Document content + pub content: String, + /// Document format + pub format: DocumentFormat, +} + +/// Document loader for YAML and Markdown formats +pub struct DocumentLoader; + +impl DocumentLoader { + /// Load a document from a file + /// + /// Automatically detects format based on file extension. + /// Supports YAML (.yaml, .yml) and Markdown (.md, .markdown) formats. + pub fn load_from_file>(path: P) -> StorageResult { + let path = path.as_ref(); + let content = std::fs::read_to_string(path).map_err(|e| { + StorageError::io_error( + path.to_path_buf(), + crate::error::IoOperation::Read, + e, + ) + })?; + + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .ok_or_else(|| { + StorageError::parse_error( + path.to_path_buf(), + "unknown", + "File has no extension", + ) + })?; + + let format = DocumentFormat::from_extension(extension).ok_or_else(|| { + StorageError::parse_error( + path.to_path_buf(), + "unknown", + format!("Unsupported document format: {}", extension), + ) + })?; + + Ok(Document { content, format }) + } + + /// Load a document from a string with specified format + pub fn load_from_string(content: String, format: DocumentFormat) -> Document { + Document { content, format } + } + + /// Save a document to a file + pub fn save_to_file>( + document: &Document, + path: P, + ) -> StorageResult<()> { + let path = path.as_ref(); + std::fs::write(path, &document.content).map_err(|e| { + StorageError::io_error( + path.to_path_buf(), + crate::error::IoOperation::Write, + e, + ) + }) + } + + /// Get the file extension for a document format + pub fn extension_for_format(format: DocumentFormat) -> &'static str { + format.extension() + } + + /// Detect format from file extension + pub fn detect_format>(path: P) -> StorageResult { + let path = path.as_ref(); + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .ok_or_else(|| { + StorageError::parse_error( + path.to_path_buf(), + "unknown", + "File has no extension", + ) + })?; + + DocumentFormat::from_extension(extension).ok_or_else(|| { + StorageError::parse_error( + path.to_path_buf(), + "unknown", + format!("Unsupported document format: {}", extension), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + use std::io::Write; + + #[test] + fn test_load_yaml_document() { + let yaml_content = "# Steering Document\nkey: value\n"; + let doc = DocumentLoader::load_from_string(yaml_content.to_string(), DocumentFormat::Yaml); + assert_eq!(doc.content, yaml_content); + assert_eq!(doc.format, DocumentFormat::Yaml); + } + + #[test] + fn test_load_markdown_document() { + let md_content = "# Steering Document\n\nThis is a markdown document.\n"; + let doc = DocumentLoader::load_from_string(md_content.to_string(), DocumentFormat::Markdown); + assert_eq!(doc.content, md_content); + assert_eq!(doc.format, DocumentFormat::Markdown); + } + + #[test] + fn test_save_and_load_yaml_document() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp dir"); + let file_path = temp_dir.path().join("steering.yaml"); + let original = Document { + content: "# Steering\nkey: value\n".to_string(), + format: DocumentFormat::Yaml, + }; + + DocumentLoader::save_to_file(&original, &file_path) + .expect("Failed to save document"); + + let loaded = DocumentLoader::load_from_file(&file_path) + .expect("Failed to load document"); + + assert_eq!(original, loaded); + } + + #[test] + fn test_save_and_load_markdown_document() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp dir"); + let file_path = temp_dir.path().join("steering.md"); + let original = Document { + content: "# Steering\n\nThis is markdown.\n".to_string(), + format: DocumentFormat::Markdown, + }; + + DocumentLoader::save_to_file(&original, &file_path) + .expect("Failed to save document"); + + let loaded = DocumentLoader::load_from_file(&file_path) + .expect("Failed to load document"); + + assert_eq!(original, loaded); + } + + #[test] + fn test_detect_yaml_format() { + let format = DocumentLoader::detect_format("test.yaml") + .expect("Failed to detect format"); + assert_eq!(format, DocumentFormat::Yaml); + + let format = DocumentLoader::detect_format("test.yml") + .expect("Failed to detect format"); + assert_eq!(format, DocumentFormat::Yaml); + } + + #[test] + fn test_detect_markdown_format() { + let format = DocumentLoader::detect_format("test.md") + .expect("Failed to detect format"); + assert_eq!(format, DocumentFormat::Markdown); + + let format = DocumentLoader::detect_format("test.markdown") + .expect("Failed to detect format"); + assert_eq!(format, DocumentFormat::Markdown); + } + + #[test] + fn test_extension_for_format() { + assert_eq!(DocumentLoader::extension_for_format(DocumentFormat::Yaml), "yaml"); + assert_eq!(DocumentLoader::extension_for_format(DocumentFormat::Markdown), "md"); + } +} diff --git a/crates/ricecoder-storage/src/config/env.rs b/crates/ricecoder-storage/src/config/env.rs new file mode 100644 index 00000000..02b27bbe --- /dev/null +++ b/crates/ricecoder-storage/src/config/env.rs @@ -0,0 +1,195 @@ +//! Environment variable override support +//! +//! This module provides parsing and application of environment variables with +//! the RICECODER_ prefix to override configuration values. + +use super::Config; +use std::collections::HashMap; + +/// Environment variable overrides +pub struct EnvOverrides; + +impl EnvOverrides { + /// Parse environment variables with RICECODER_ prefix + /// + /// Returns a map of configuration paths to values. + /// For example, RICECODER_PROVIDERS_DEFAULT=openai becomes + /// {"providers.default_provider": "openai"} + pub fn parse() -> HashMap { + let mut overrides = HashMap::new(); + + for (key, value) in std::env::vars() { + if let Some(config_key) = key.strip_prefix("RICECODER_") { + let config_path = Self::env_key_to_config_path(config_key); + overrides.insert(config_path, value); + } + } + + overrides + } + + /// Apply environment variable overrides to configuration + /// + /// This function applies environment variable overrides to the configuration + /// by parsing the environment and updating the config accordingly. + pub fn apply(config: &mut Config) { + let overrides = Self::parse(); + Self::apply_overrides(config, &overrides); + } + + /// Apply specific overrides to configuration + pub fn apply_overrides(config: &mut Config, overrides: &HashMap) { + for (path, value) in overrides { + Self::set_config_value(config, path, value); + } + } + + /// Convert environment variable key to configuration path + /// + /// Examples: + /// - PROVIDERS_DEFAULT -> providers.default_provider + /// - PROVIDERS_API_KEYS_OPENAI -> providers.api_keys.openai + /// - DEFAULTS_MODEL -> defaults.model + fn env_key_to_config_path(key: &str) -> String { + key.to_lowercase() + .replace('_', ".") + } + + /// Set a configuration value by path + /// + /// Supports nested paths like "providers.default_provider" + fn set_config_value(config: &mut Config, path: &str, value: &str) { + let parts: Vec<&str> = path.split('.').collect(); + + match parts.as_slice() { + ["providers", "default_provider"] => { + config.providers.default_provider = Some(value.to_string()); + } + ["providers", "api_keys", key] => { + config.providers.api_keys.insert(key.to_string(), value.to_string()); + } + ["providers", "endpoints", key] => { + config.providers.endpoints.insert(key.to_string(), value.to_string()); + } + ["defaults", "model"] => { + config.defaults.model = Some(value.to_string()); + } + ["defaults", "temperature"] => { + if let Ok(temp) = value.parse::() { + config.defaults.temperature = Some(temp); + } + } + ["defaults", "max_tokens"] => { + if let Ok(tokens) = value.parse::() { + config.defaults.max_tokens = Some(tokens); + } + } + _ => { + // Store in custom map for unknown paths + if let Ok(json_value) = serde_json::from_str(value) { + config.custom.insert(path.to_string(), json_value); + } else { + config.custom.insert( + path.to_string(), + serde_json::Value::String(value.to_string()), + ); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_env_key_to_config_path() { + assert_eq!( + EnvOverrides::env_key_to_config_path("PROVIDERS_DEFAULT"), + "providers.default" + ); + assert_eq!( + EnvOverrides::env_key_to_config_path("DEFAULTS_MODEL"), + "defaults.model" + ); + } + + #[test] + fn test_apply_provider_default_override() { + let mut config = Config::default(); + let mut overrides = HashMap::new(); + overrides.insert("providers.default_provider".to_string(), "openai".to_string()); + + EnvOverrides::apply_overrides(&mut config, &overrides); + + assert_eq!(config.providers.default_provider, Some("openai".to_string())); + } + + #[test] + fn test_apply_api_key_override() { + let mut config = Config::default(); + let mut overrides = HashMap::new(); + overrides.insert( + "providers.api_keys.openai".to_string(), + "test-key".to_string(), + ); + + EnvOverrides::apply_overrides(&mut config, &overrides); + + assert_eq!( + config.providers.api_keys.get("openai"), + Some(&"test-key".to_string()) + ); + } + + #[test] + fn test_apply_defaults_override() { + let mut config = Config::default(); + let mut overrides = HashMap::new(); + overrides.insert("defaults.model".to_string(), "gpt-4".to_string()); + overrides.insert("defaults.temperature".to_string(), "0.7".to_string()); + overrides.insert("defaults.max_tokens".to_string(), "2000".to_string()); + + EnvOverrides::apply_overrides(&mut config, &overrides); + + assert_eq!(config.defaults.model, Some("gpt-4".to_string())); + assert_eq!(config.defaults.temperature, Some(0.7)); + assert_eq!(config.defaults.max_tokens, Some(2000)); + } + + #[test] + fn test_apply_multiple_overrides() { + let mut config = Config::default(); + let mut overrides = HashMap::new(); + overrides.insert("providers.default_provider".to_string(), "openai".to_string()); + overrides.insert("defaults.model".to_string(), "gpt-4".to_string()); + overrides.insert( + "providers.api_keys.openai".to_string(), + "test-key".to_string(), + ); + + EnvOverrides::apply_overrides(&mut config, &overrides); + + assert_eq!(config.providers.default_provider, Some("openai".to_string())); + assert_eq!(config.defaults.model, Some("gpt-4".to_string())); + assert_eq!( + config.providers.api_keys.get("openai"), + Some(&"test-key".to_string()) + ); + } + + #[test] + fn test_apply_custom_override() { + let mut config = Config::default(); + let mut overrides = HashMap::new(); + overrides.insert("custom.setting".to_string(), "value".to_string()); + + EnvOverrides::apply_overrides(&mut config, &overrides); + + assert_eq!( + config.custom.get("custom.setting"), + Some(&serde_json::Value::String("value".to_string())) + ); + } +} diff --git a/crates/ricecoder-storage/src/config/loader.rs b/crates/ricecoder-storage/src/config/loader.rs new file mode 100644 index 00000000..9d288b9f --- /dev/null +++ b/crates/ricecoder-storage/src/config/loader.rs @@ -0,0 +1,271 @@ +//! Configuration file loader supporting multiple formats +//! +//! This module provides loading of configuration files in YAML, TOML, and JSON formats. +//! It automatically detects the format based on file extension. + +use crate::error::{StorageError, StorageResult}; +use crate::types::ConfigFormat; +use std::path::Path; +use super::Config; + +/// Configuration loader for multiple formats +pub struct ConfigLoader; + +impl ConfigLoader { + /// Load configuration from a file + /// + /// Automatically detects format based on file extension. + /// Supports YAML (.yaml, .yml), TOML (.toml), and JSON (.json) formats. + pub fn load_from_file>(path: P) -> StorageResult { + let path = path.as_ref(); + let content = std::fs::read_to_string(path).map_err(|e| { + StorageError::io_error( + path.to_path_buf(), + crate::error::IoOperation::Read, + e, + ) + })?; + + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .ok_or_else(|| { + StorageError::parse_error( + path.to_path_buf(), + "unknown", + "File has no extension", + ) + })?; + + let format = ConfigFormat::from_extension(extension).ok_or_else(|| { + StorageError::parse_error( + path.to_path_buf(), + "unknown", + format!("Unsupported file format: {}", extension), + ) + })?; + + Self::load_from_string(&content, format, path) + } + + /// Load configuration from a string with specified format + pub fn load_from_string>( + content: &str, + format: ConfigFormat, + path: P, + ) -> StorageResult { + let path = path.as_ref(); + match format { + ConfigFormat::Yaml => Self::parse_yaml(content, path), + ConfigFormat::Toml => Self::parse_toml(content, path), + ConfigFormat::Json => Self::parse_json(content, path), + } + } + + /// Parse YAML content + fn parse_yaml>(content: &str, path: P) -> StorageResult { + let path = path.as_ref(); + serde_yaml::from_str(content).map_err(|e| { + StorageError::parse_error( + path.to_path_buf(), + "YAML", + e.to_string(), + ) + }) + } + + /// Parse TOML content + fn parse_toml>(content: &str, path: P) -> StorageResult { + let path = path.as_ref(); + toml::from_str(content).map_err(|e| { + StorageError::parse_error( + path.to_path_buf(), + "TOML", + e.to_string(), + ) + }) + } + + /// Parse JSON content + fn parse_json>(content: &str, path: P) -> StorageResult { + let path = path.as_ref(); + serde_json::from_str(content).map_err(|e| { + StorageError::parse_error( + path.to_path_buf(), + "JSON", + e.to_string(), + ) + }) + } + + /// Serialize configuration to string in specified format + pub fn serialize(config: &Config, format: ConfigFormat) -> StorageResult { + match format { + ConfigFormat::Yaml => serde_yaml::to_string(config).map_err(|e| { + StorageError::Internal(format!("Failed to serialize to YAML: {}", e)) + }), + ConfigFormat::Toml => toml::to_string_pretty(config).map_err(|e| { + StorageError::Internal(format!("Failed to serialize to TOML: {}", e)) + }), + ConfigFormat::Json => serde_json::to_string_pretty(config).map_err(|e| { + StorageError::Internal(format!("Failed to serialize to JSON: {}", e)) + }), + } + } + + /// Save configuration to a file + pub fn save_to_file>( + config: &Config, + path: P, + format: ConfigFormat, + ) -> StorageResult<()> { + let path = path.as_ref(); + let content = Self::serialize(config, format)?; + std::fs::write(path, content).map_err(|e| { + StorageError::io_error( + path.to_path_buf(), + crate::error::IoOperation::Write, + e, + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + use std::io::Write; + + #[test] + fn test_load_yaml_config() { + let yaml_content = r#" +providers: + default_provider: openai + api_keys: + openai: test-key +defaults: + model: gpt-4 + temperature: 0.7 +steering: [] +"#; + let config = ConfigLoader::load_from_string(yaml_content, ConfigFormat::Yaml, "test.yaml") + .expect("Failed to parse YAML"); + assert_eq!(config.providers.default_provider, Some("openai".to_string())); + assert_eq!(config.defaults.model, Some("gpt-4".to_string())); + } + + #[test] + fn test_load_toml_config() { + let toml_content = r#"[providers] +default_provider = "openai" +api_keys = { openai = "test-key" } +endpoints = {} + +[defaults] +model = "gpt-4" +temperature = 0.7 + +steering = [] +custom = {} +"#; + let config = ConfigLoader::load_from_string(toml_content, ConfigFormat::Toml, "test.toml") + .expect("Failed to parse TOML"); + assert_eq!(config.providers.default_provider, Some("openai".to_string())); + assert_eq!(config.defaults.model, Some("gpt-4".to_string())); + } + + #[test] + fn test_load_json_config() { + let json_content = r#"{ + "providers": { + "default_provider": "openai", + "api_keys": { + "openai": "test-key" + }, + "endpoints": {} + }, + "defaults": { + "model": "gpt-4", + "temperature": 0.7 + }, + "steering": [] +}"#; + let config = ConfigLoader::load_from_string(json_content, ConfigFormat::Json, "test.json") + .expect("Failed to parse JSON"); + assert_eq!(config.providers.default_provider, Some("openai".to_string())); + assert_eq!(config.defaults.model, Some("gpt-4".to_string())); + } + + #[test] + fn test_serialize_yaml() { + let config = Config::default(); + let yaml = ConfigLoader::serialize(&config, ConfigFormat::Yaml) + .expect("Failed to serialize to YAML"); + assert!(yaml.contains("providers:")); + assert!(yaml.contains("defaults:")); + } + + #[test] + fn test_serialize_toml() { + let config = Config::default(); + let toml = ConfigLoader::serialize(&config, ConfigFormat::Toml) + .expect("Failed to serialize to TOML"); + assert!(toml.contains("providers") || toml.contains("[providers]")); + assert!(toml.contains("defaults") || toml.contains("[defaults]")); + } + + #[test] + fn test_serialize_json() { + let config = Config::default(); + let json = ConfigLoader::serialize(&config, ConfigFormat::Json) + .expect("Failed to serialize to JSON"); + assert!(json.contains("\"providers\"")); + assert!(json.contains("\"defaults\"")); + } + + #[test] + fn test_save_and_load_yaml() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp dir"); + let file_path = temp_dir.path().join("config.yaml"); + let config = Config::default(); + + ConfigLoader::save_to_file(&config, &file_path, ConfigFormat::Yaml) + .expect("Failed to save config"); + + let loaded = ConfigLoader::load_from_file(&file_path) + .expect("Failed to load config"); + + assert_eq!(config, loaded); + } + + #[test] + fn test_save_and_load_toml() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp dir"); + let file_path = temp_dir.path().join("config.toml"); + let config = Config::default(); + + ConfigLoader::save_to_file(&config, &file_path, ConfigFormat::Toml) + .expect("Failed to save config"); + + let loaded = ConfigLoader::load_from_file(&file_path) + .expect("Failed to load config"); + + assert_eq!(config, loaded); + } + + #[test] + fn test_save_and_load_json() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp dir"); + let file_path = temp_dir.path().join("config.json"); + let config = Config::default(); + + ConfigLoader::save_to_file(&config, &file_path, ConfigFormat::Json) + .expect("Failed to save config"); + + let loaded = ConfigLoader::load_from_file(&file_path) + .expect("Failed to load config"); + + assert_eq!(config, loaded); + } +} diff --git a/crates/ricecoder-storage/src/config/merge.rs b/crates/ricecoder-storage/src/config/merge.rs new file mode 100644 index 00000000..5e17f43c --- /dev/null +++ b/crates/ricecoder-storage/src/config/merge.rs @@ -0,0 +1,254 @@ +//! Configuration merging with precedence rules +//! +//! This module provides configuration merging with the following precedence: +//! environment > project > legacy > global > defaults + +use super::Config; +use tracing::debug; + +/// Configuration merger +pub struct ConfigMerger; + +/// Merge decision for logging +#[derive(Debug, Clone)] +pub struct MergeDecision { + /// The key that was merged + pub key: String, + /// The source of the value + pub source: String, + /// The value that was applied + pub value: String, +} + +impl ConfigMerger { + /// Merge configurations with precedence rules + /// + /// Precedence: env > project > legacy > global > defaults + /// + /// Returns the merged configuration and a list of merge decisions for logging. + pub fn merge( + defaults: Config, + global: Option, + project: Option, + env_overrides: Option, + ) -> (Config, Vec) { + let mut decisions = Vec::new(); + let mut result = defaults; + + // Apply global config + if let Some(global_config) = global { + Self::merge_into(&mut result, &global_config, "global", &mut decisions); + } + + // Apply project config (overrides global) + if let Some(project_config) = project { + Self::merge_into(&mut result, &project_config, "project", &mut decisions); + } + + // Apply environment overrides (highest priority) + if let Some(env_config) = env_overrides { + Self::merge_into(&mut result, &env_config, "environment", &mut decisions); + } + + // Log merge decisions + for decision in &decisions { + debug!( + key = %decision.key, + source = %decision.source, + value = %decision.value, + "Configuration merged" + ); + } + + (result, decisions) + } + + /// Merge one configuration into another + fn merge_into(target: &mut Config, source: &Config, source_name: &str, decisions: &mut Vec) { + // Merge providers + if let Some(ref provider) = source.providers.default_provider { + if target.providers.default_provider != source.providers.default_provider { + decisions.push(MergeDecision { + key: "providers.default_provider".to_string(), + source: source_name.to_string(), + value: provider.clone(), + }); + target.providers.default_provider = Some(provider.clone()); + } + } + + for (key, value) in &source.providers.api_keys { + if !target.providers.api_keys.contains_key(key) { + decisions.push(MergeDecision { + key: format!("providers.api_keys.{}", key), + source: source_name.to_string(), + value: value.clone(), + }); + } + target.providers.api_keys.insert(key.clone(), value.clone()); + } + + for (key, value) in &source.providers.endpoints { + if !target.providers.endpoints.contains_key(key) { + decisions.push(MergeDecision { + key: format!("providers.endpoints.{}", key), + source: source_name.to_string(), + value: value.clone(), + }); + } + target.providers.endpoints.insert(key.clone(), value.clone()); + } + + // Merge defaults + if let Some(ref model) = source.defaults.model { + if target.defaults.model != source.defaults.model { + decisions.push(MergeDecision { + key: "defaults.model".to_string(), + source: source_name.to_string(), + value: model.clone(), + }); + target.defaults.model = Some(model.clone()); + } + } + + if let Some(temp) = source.defaults.temperature { + if target.defaults.temperature != source.defaults.temperature { + decisions.push(MergeDecision { + key: "defaults.temperature".to_string(), + source: source_name.to_string(), + value: temp.to_string(), + }); + target.defaults.temperature = Some(temp); + } + } + + if let Some(tokens) = source.defaults.max_tokens { + if target.defaults.max_tokens != source.defaults.max_tokens { + decisions.push(MergeDecision { + key: "defaults.max_tokens".to_string(), + source: source_name.to_string(), + value: tokens.to_string(), + }); + target.defaults.max_tokens = Some(tokens); + } + } + + // Merge steering + for rule in &source.steering { + if !target.steering.iter().any(|r| r.name == rule.name) { + decisions.push(MergeDecision { + key: format!("steering.{}", rule.name), + source: source_name.to_string(), + value: format!("{} bytes", rule.content.len()), + }); + target.steering.push(rule.clone()); + } + } + + // Merge custom settings + for (key, value) in &source.custom { + if !target.custom.contains_key(key) { + decisions.push(MergeDecision { + key: key.clone(), + source: source_name.to_string(), + value: value.to_string(), + }); + } + target.custom.insert(key.clone(), value.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_merge_global_into_defaults() { + let defaults = Config::default(); + let mut global = Config::default(); + global.defaults.model = Some("gpt-4".to_string()); + + let (result, decisions) = ConfigMerger::merge(defaults, Some(global), None, None); + + assert_eq!(result.defaults.model, Some("gpt-4".to_string())); + assert_eq!(decisions.len(), 1); + assert_eq!(decisions[0].source, "global"); + } + + #[test] + fn test_merge_project_overrides_global() { + let defaults = Config::default(); + let mut global = Config::default(); + global.defaults.model = Some("gpt-4".to_string()); + + let mut project = Config::default(); + project.defaults.model = Some("gpt-3.5".to_string()); + + let (result, decisions) = ConfigMerger::merge(defaults, Some(global), Some(project), None); + + assert_eq!(result.defaults.model, Some("gpt-3.5".to_string())); + // Should have 2 decisions: one for global, one for project override + assert!(decisions.iter().any(|d| d.source == "project")); + } + + #[test] + fn test_merge_env_overrides_all() { + let defaults = Config::default(); + let mut global = Config::default(); + global.defaults.model = Some("gpt-4".to_string()); + + let mut env = Config::default(); + env.defaults.model = Some("gpt-3.5-turbo".to_string()); + + let (result, decisions) = ConfigMerger::merge(defaults, Some(global), None, Some(env)); + + assert_eq!(result.defaults.model, Some("gpt-3.5-turbo".to_string())); + assert!(decisions.iter().any(|d| d.source == "environment")); + } + + #[test] + fn test_merge_api_keys() { + let defaults = Config::default(); + let mut global = Config::default(); + global.providers.api_keys.insert("openai".to_string(), "key1".to_string()); + + let mut project = Config::default(); + project.providers.api_keys.insert("anthropic".to_string(), "key2".to_string()); + + let (result, _) = ConfigMerger::merge(defaults, Some(global), Some(project), None); + + assert_eq!(result.providers.api_keys.get("openai"), Some(&"key1".to_string())); + assert_eq!(result.providers.api_keys.get("anthropic"), Some(&"key2".to_string())); + } + + #[test] + fn test_merge_decisions_logged() { + let defaults = Config::default(); + let mut global = Config::default(); + global.defaults.model = Some("gpt-4".to_string()); + global.defaults.temperature = Some(0.7); + + let (_, decisions) = ConfigMerger::merge(defaults, Some(global), None, None); + + assert_eq!(decisions.len(), 2); + assert!(decisions.iter().any(|d| d.key == "defaults.model")); + assert!(decisions.iter().any(|d| d.key == "defaults.temperature")); + } + + #[test] + fn test_merge_no_duplicate_decisions() { + let defaults = Config::default(); + let mut global = Config::default(); + global.defaults.model = Some("gpt-4".to_string()); + + let mut project = Config::default(); + project.defaults.model = Some("gpt-4".to_string()); // Same as global + + let (_, decisions) = ConfigMerger::merge(defaults, Some(global), Some(project), None); + + // Should only have one decision for model (from global), not from project + let model_decisions: Vec<_> = decisions.iter().filter(|d| d.key == "defaults.model").collect(); + assert_eq!(model_decisions.len(), 1); + } +} diff --git a/crates/ricecoder-storage/src/config/mod.rs b/crates/ricecoder-storage/src/config/mod.rs new file mode 100644 index 00000000..d549d8e6 --- /dev/null +++ b/crates/ricecoder-storage/src/config/mod.rs @@ -0,0 +1,102 @@ +//! Configuration loading and management +//! +//! This module provides configuration loading from multiple formats (YAML, TOML, JSON), +//! environment variable overrides, and configuration merging with precedence rules. + +pub mod loader; +pub mod documents; +pub mod env; +pub mod merge; +pub mod modes; + +// Re-export commonly used types +pub use loader::ConfigLoader; +pub use documents::{DocumentLoader, Document}; +pub use env::EnvOverrides; +pub use merge::ConfigMerger; +pub use modes::StorageModeHandler; + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Main configuration structure +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Config { + /// Provider configurations + pub providers: ProvidersConfig, + /// Default settings + pub defaults: DefaultsConfig, + /// Steering rules + #[serde(default)] + pub steering: Vec, + /// Additional custom settings + #[serde(default)] + pub custom: HashMap, +} + +/// Provider configuration +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ProvidersConfig { + /// API keys for various providers + #[serde(default)] + pub api_keys: HashMap, + /// Endpoints for various providers + #[serde(default)] + pub endpoints: HashMap, + /// Default provider to use + pub default_provider: Option, +} + +/// Default settings +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DefaultsConfig { + /// Default model to use + pub model: Option, + /// Default temperature for LLM + pub temperature: Option, + /// Default max tokens for LLM + pub max_tokens: Option, +} + +/// Steering rule +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SteeringRule { + /// Rule name + pub name: String, + /// Rule content + pub content: String, + /// Format of the rule + pub format: crate::types::DocumentFormat, +} + +impl Default for Config { + fn default() -> Self { + Config { + providers: ProvidersConfig { + api_keys: HashMap::new(), + endpoints: HashMap::new(), + default_provider: None, + }, + defaults: DefaultsConfig { + model: None, + temperature: None, + max_tokens: None, + }, + steering: Vec::new(), + custom: HashMap::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_default() { + let config = Config::default(); + assert!(config.providers.api_keys.is_empty()); + assert!(config.defaults.model.is_none()); + assert!(config.steering.is_empty()); + } +} diff --git a/crates/ricecoder-storage/src/config/modes.rs b/crates/ricecoder-storage/src/config/modes.rs new file mode 100644 index 00000000..e3d4480e --- /dev/null +++ b/crates/ricecoder-storage/src/config/modes.rs @@ -0,0 +1,279 @@ +//! Storage mode handling +//! +//! This module provides handling for different storage modes: +//! - GlobalOnly: Only use global storage +//! - ProjectOnly: Only use project storage +//! - Merged: Use both global and project with project overriding global + +use super::Config; +use crate::types::StorageMode; +use std::path::Path; + +/// Storage mode handler +pub struct StorageModeHandler; + +impl StorageModeHandler { + /// Load configuration based on storage mode + /// + /// - GlobalOnly: Load only from global path + /// - ProjectOnly: Load only from project path + /// - Merged: Load from both, with project overriding global + pub fn load_for_mode( + mode: StorageMode, + global_path: Option<&Path>, + project_path: Option<&Path>, + ) -> crate::error::StorageResult { + match mode { + StorageMode::GlobalOnly => { + Self::load_global_only(global_path) + } + StorageMode::ProjectOnly => { + Self::load_project_only(project_path) + } + StorageMode::Merged => { + Self::load_merged(global_path, project_path) + } + } + } + + /// Load configuration from global storage only + fn load_global_only(global_path: Option<&Path>) -> crate::error::StorageResult { + if let Some(path) = global_path { + let config_file = path.join("config.yaml"); + if config_file.exists() { + return super::loader::ConfigLoader::load_from_file(&config_file); + } + } + Ok(Config::default()) + } + + /// Load configuration from project storage only + fn load_project_only(project_path: Option<&Path>) -> crate::error::StorageResult { + if let Some(path) = project_path { + let config_file = path.join("config.yaml"); + if config_file.exists() { + return super::loader::ConfigLoader::load_from_file(&config_file); + } + } + Ok(Config::default()) + } + + /// Load configuration from both global and project, with project overriding global + fn load_merged( + global_path: Option<&Path>, + project_path: Option<&Path>, + ) -> crate::error::StorageResult { + let global_config = if let Some(path) = global_path { + let config_file = path.join("config.yaml"); + if config_file.exists() { + super::loader::ConfigLoader::load_from_file(&config_file).ok() + } else { + None + } + } else { + None + }; + + let project_config = if let Some(path) = project_path { + let config_file = path.join("config.yaml"); + if config_file.exists() { + super::loader::ConfigLoader::load_from_file(&config_file).ok() + } else { + None + } + } else { + None + }; + + let (merged, _) = super::merge::ConfigMerger::merge( + Config::default(), + global_config, + project_config, + None, + ); + + Ok(merged) + } + + /// Verify that a mode is properly isolated + /// + /// For GlobalOnly mode, ensures no project config is loaded. + /// For ProjectOnly mode, ensures no global config is loaded. + pub fn verify_isolation( + mode: StorageMode, + global_path: Option<&Path>, + project_path: Option<&Path>, + ) -> crate::error::StorageResult { + match mode { + StorageMode::GlobalOnly => { + // Verify that project config is not loaded + if let Some(path) = project_path { + let config_file = path.join("config.yaml"); + Ok(!config_file.exists()) + } else { + Ok(true) + } + } + StorageMode::ProjectOnly => { + // Verify that global config is not loaded + if let Some(path) = global_path { + let config_file = path.join("config.yaml"); + Ok(!config_file.exists()) + } else { + Ok(true) + } + } + StorageMode::Merged => { + // Merged mode doesn't have isolation requirements + Ok(true) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + use std::fs; + + #[test] + fn test_global_only_mode_loads_global() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let global_path = temp_dir.path(); + + // Create a global config + let config_file = global_path.join("config.yaml"); + let config_content = r#" +providers: + default_provider: openai +defaults: + model: gpt-4 +steering: [] +"#; + fs::write(&config_file, config_content).expect("Failed to write config"); + + let config = StorageModeHandler::load_for_mode( + StorageMode::GlobalOnly, + Some(global_path), + None, + ).expect("Failed to load config"); + + assert_eq!(config.providers.default_provider, Some("openai".to_string())); + assert_eq!(config.defaults.model, Some("gpt-4".to_string())); + } + + #[test] + fn test_project_only_mode_loads_project() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let project_path = temp_dir.path(); + + // Create a project config + let config_file = project_path.join("config.yaml"); + let config_content = r#" +providers: + default_provider: anthropic +defaults: + model: claude-3 +steering: [] +"#; + fs::write(&config_file, config_content).expect("Failed to write config"); + + let config = StorageModeHandler::load_for_mode( + StorageMode::ProjectOnly, + None, + Some(project_path), + ).expect("Failed to load config"); + + assert_eq!(config.providers.default_provider, Some("anthropic".to_string())); + assert_eq!(config.defaults.model, Some("claude-3".to_string())); + } + + #[test] + fn test_merged_mode_project_overrides_global() { + let global_dir = TempDir::new().expect("Failed to create temp dir"); + let project_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create global config + let global_config_file = global_dir.path().join("config.yaml"); + let global_content = r#" +providers: + default_provider: openai +defaults: + model: gpt-4 +steering: [] +"#; + fs::write(&global_config_file, global_content).expect("Failed to write global config"); + + // Create project config + let project_config_file = project_dir.path().join("config.yaml"); + let project_content = r#" +providers: + default_provider: anthropic +defaults: + model: claude-3 +steering: [] +"#; + fs::write(&project_config_file, project_content).expect("Failed to write project config"); + + let config = StorageModeHandler::load_for_mode( + StorageMode::Merged, + Some(global_dir.path()), + Some(project_dir.path()), + ).expect("Failed to load config"); + + // Project should override global + assert_eq!(config.providers.default_provider, Some("anthropic".to_string())); + assert_eq!(config.defaults.model, Some("claude-3".to_string())); + } + + #[test] + fn test_global_only_isolation() { + let global_dir = TempDir::new().expect("Failed to create temp dir"); + let project_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create both configs + let global_config_file = global_dir.path().join("config.yaml"); + fs::write(&global_config_file, "providers:\n default_provider: openai\ndefaults: {}\nsteering: []") + .expect("Failed to write global config"); + + let project_config_file = project_dir.path().join("config.yaml"); + fs::write(&project_config_file, "providers:\n default_provider: anthropic\ndefaults: {}\nsteering: []") + .expect("Failed to write project config"); + + // Load in GlobalOnly mode + let config = StorageModeHandler::load_for_mode( + StorageMode::GlobalOnly, + Some(global_dir.path()), + Some(project_dir.path()), + ).expect("Failed to load config"); + + // Should only have global config + assert_eq!(config.providers.default_provider, Some("openai".to_string())); + } + + #[test] + fn test_project_only_isolation() { + let global_dir = TempDir::new().expect("Failed to create temp dir"); + let project_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create both configs + let global_config_file = global_dir.path().join("config.yaml"); + fs::write(&global_config_file, "providers:\n default_provider: openai\ndefaults: {}\nsteering: []") + .expect("Failed to write global config"); + + let project_config_file = project_dir.path().join("config.yaml"); + fs::write(&project_config_file, "providers:\n default_provider: anthropic\ndefaults: {}\nsteering: []") + .expect("Failed to write project config"); + + // Load in ProjectOnly mode + let config = StorageModeHandler::load_for_mode( + StorageMode::ProjectOnly, + Some(global_dir.path()), + Some(project_dir.path()), + ).expect("Failed to load config"); + + // Should only have project config + assert_eq!(config.providers.default_provider, Some("anthropic".to_string())); + } +} diff --git a/crates/ricecoder-storage/src/first_run.rs b/crates/ricecoder-storage/src/first_run.rs index 272f2544..df8efa69 100644 --- a/crates/ricecoder-storage/src/first_run.rs +++ b/crates/ricecoder-storage/src/first_run.rs @@ -6,7 +6,7 @@ use crate::error::StorageResult; use crate::manager::PathResolver; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// Marker file name for tracking first-run status const FIRST_RUN_MARKER: &str = ".ricecoder-initialized"; @@ -18,7 +18,7 @@ impl FirstRunHandler { /// Check if this is the first run /// /// Returns true if the marker file doesn't exist in the global storage path - pub fn is_first_run(global_path: &PathBuf) -> StorageResult { + pub fn is_first_run(global_path: &Path) -> StorageResult { let marker_path = global_path.join(FIRST_RUN_MARKER); Ok(!marker_path.exists()) } @@ -26,7 +26,7 @@ impl FirstRunHandler { /// Mark the first run as complete /// /// Creates the marker file to indicate initialization is done - pub fn mark_first_run_complete(global_path: &PathBuf) -> StorageResult<()> { + pub fn mark_first_run_complete(global_path: &Path) -> StorageResult<()> { let marker_path = global_path.join(FIRST_RUN_MARKER); // Ensure parent directory exists @@ -84,7 +84,7 @@ mod tests { #[test] fn test_is_first_run_no_marker() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let is_first = FirstRunHandler::is_first_run(&temp_dir.path().to_path_buf()) + let is_first = FirstRunHandler::is_first_run(temp_dir.path()) .expect("Failed to check first run"); assert!(is_first, "Should be first run when marker doesn't exist"); } @@ -95,7 +95,7 @@ mod tests { let marker_path = temp_dir.path().join(FIRST_RUN_MARKER); fs::write(&marker_path, "").expect("Failed to create marker"); - let is_first = FirstRunHandler::is_first_run(&temp_dir.path().to_path_buf()) + let is_first = FirstRunHandler::is_first_run(temp_dir.path()) .expect("Failed to check first run"); assert!(!is_first, "Should not be first run when marker exists"); } diff --git a/crates/ricecoder-storage/src/industry/adapter.rs b/crates/ricecoder-storage/src/industry/adapter.rs new file mode 100644 index 00000000..8f808817 --- /dev/null +++ b/crates/ricecoder-storage/src/industry/adapter.rs @@ -0,0 +1,209 @@ +//! Industry file adapter trait and implementations +//! +//! This module defines the interface for reading and converting configuration files +//! from other AI coding tools (Cursor, Claude, Windsurf, etc.) into RiceCoder's +//! internal configuration format. + +use crate::config::Config; +use crate::error::StorageResult; +use std::path::Path; + +/// Trait for adapting industry-standard configuration files to RiceCoder format +pub trait IndustryFileAdapter: Send + Sync { + /// Get the name of this adapter (e.g., "cursor", "claude", "windsurf") + fn name(&self) -> &'static str; + + /// Check if this adapter can handle files in the given directory + fn can_handle(&self, project_root: &Path) -> bool; + + /// Read and convert industry-standard config to RiceCoder Config + fn read_config(&self, project_root: &Path) -> StorageResult; + + /// Get the priority of this adapter (higher = higher priority) + /// Used when multiple adapters can handle the same directory + fn priority(&self) -> u32 { + 0 + } +} + +/// File detection result +#[derive(Debug, Clone)] +pub struct FileDetectionResult { + /// Name of the adapter that can handle this file + pub adapter_name: String, + /// Priority of the adapter + pub priority: u32, + /// Path to the detected file + pub file_path: std::path::PathBuf, +} + +/// Industry file detector +pub struct IndustryFileDetector { + adapters: Vec>, +} + +impl IndustryFileDetector { + /// Create a new detector with the given adapters + pub fn new(adapters: Vec>) -> Self { + IndustryFileDetector { adapters } + } + + /// Detect which industry files exist in the project root + pub fn detect_files(&self, project_root: &Path) -> Vec { + let mut results = Vec::new(); + + for adapter in &self.adapters { + if adapter.can_handle(project_root) { + // For now, we just record that this adapter can handle the directory + // The actual file path detection is done by each adapter + results.push(FileDetectionResult { + adapter_name: adapter.name().to_string(), + priority: adapter.priority(), + file_path: project_root.to_path_buf(), + }); + } + } + + // Sort by priority (highest first) + results.sort_by(|a, b| b.priority.cmp(&a.priority)); + results + } + + /// Get the highest priority adapter that can handle the project + pub fn get_best_adapter(&self, project_root: &Path) -> Option<&dyn IndustryFileAdapter> { + self.adapters + .iter() + .filter(|adapter| adapter.can_handle(project_root)) + .max_by_key(|adapter| adapter.priority()) + .map(|adapter| adapter.as_ref()) + } + + /// Register a new adapter + pub fn register_adapter(&mut self, adapter: Box) { + self.adapters.push(adapter); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MockAdapter { + name: &'static str, + priority: u32, + can_handle: bool, + } + + impl IndustryFileAdapter for MockAdapter { + fn name(&self) -> &'static str { + self.name + } + + fn can_handle(&self, _project_root: &Path) -> bool { + self.can_handle + } + + fn read_config(&self, _project_root: &Path) -> StorageResult { + Ok(Config::default()) + } + + fn priority(&self) -> u32 { + self.priority + } + } + + #[test] + fn test_detector_sorts_by_priority() { + let adapters: Vec> = vec![ + Box::new(MockAdapter { + name: "low", + priority: 1, + can_handle: true, + }), + Box::new(MockAdapter { + name: "high", + priority: 10, + can_handle: true, + }), + Box::new(MockAdapter { + name: "medium", + priority: 5, + can_handle: true, + }), + ]; + + let detector = IndustryFileDetector::new(adapters); + let results = detector.detect_files(Path::new(".")); + + assert_eq!(results.len(), 3); + assert_eq!(results[0].adapter_name, "high"); + assert_eq!(results[1].adapter_name, "medium"); + assert_eq!(results[2].adapter_name, "low"); + } + + #[test] + fn test_detector_filters_by_can_handle() { + let adapters: Vec> = vec![ + Box::new(MockAdapter { + name: "yes", + priority: 1, + can_handle: true, + }), + Box::new(MockAdapter { + name: "no", + priority: 10, + can_handle: false, + }), + ]; + + let detector = IndustryFileDetector::new(adapters); + let results = detector.detect_files(Path::new(".")); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].adapter_name, "yes"); + } + + #[test] + fn test_get_best_adapter() { + let adapters: Vec> = vec![ + Box::new(MockAdapter { + name: "low", + priority: 1, + can_handle: true, + }), + Box::new(MockAdapter { + name: "high", + priority: 10, + can_handle: true, + }), + ]; + + let detector = IndustryFileDetector::new(adapters); + let best = detector.get_best_adapter(Path::new(".")); + + assert!(best.is_some()); + assert_eq!(best.unwrap().name(), "high"); + } + + #[test] + fn test_get_best_adapter_respects_can_handle() { + let adapters: Vec> = vec![ + Box::new(MockAdapter { + name: "low", + priority: 1, + can_handle: true, + }), + Box::new(MockAdapter { + name: "high", + priority: 10, + can_handle: false, + }), + ]; + + let detector = IndustryFileDetector::new(adapters); + let best = detector.get_best_adapter(Path::new(".")); + + assert!(best.is_some()); + assert_eq!(best.unwrap().name(), "low"); + } +} diff --git a/crates/ricecoder-storage/src/industry/agents.rs b/crates/ricecoder-storage/src/industry/agents.rs new file mode 100644 index 00000000..fa34b006 --- /dev/null +++ b/crates/ricecoder-storage/src/industry/agents.rs @@ -0,0 +1,132 @@ +//! Generic agent configuration adapter +//! +//! Reads and converts generic agent configuration files (AGENTS.md) +//! into RiceCoder's internal configuration format. + +use crate::config::{Config, SteeringRule}; +use crate::error::StorageResult; +use crate::types::DocumentFormat; +use std::path::Path; +use tracing::debug; + +use super::adapter::IndustryFileAdapter; + +/// Generic agents adapter +pub struct AgentsAdapter; + +impl AgentsAdapter { + /// Create a new agents adapter + pub fn new() -> Self { + AgentsAdapter + } + + /// Read AGENTS.md file + fn read_agents_md(&self, project_root: &Path) -> StorageResult> { + let agents_path = project_root.join("AGENTS.md"); + + if !agents_path.exists() { + debug!("No AGENTS.md file found at {:?}", agents_path); + return Ok(None); + } + + debug!("Reading AGENTS.md from {:?}", agents_path); + let content = std::fs::read_to_string(&agents_path).map_err(|e| { + crate::error::StorageError::io_error( + agents_path.clone(), + crate::error::IoOperation::Read, + e, + ) + })?; + + Ok(Some(content)) + } +} + +impl Default for AgentsAdapter { + fn default() -> Self { + Self::new() + } +} + +impl IndustryFileAdapter for AgentsAdapter { + fn name(&self) -> &'static str { + "agents" + } + + fn can_handle(&self, project_root: &Path) -> bool { + project_root.join("AGENTS.md").exists() + } + + fn read_config(&self, project_root: &Path) -> StorageResult { + let mut config = Config::default(); + + if let Ok(Some(agents_content)) = self.read_agents_md(project_root) { + debug!("Adding agent instructions as steering rule"); + config.steering.push(SteeringRule { + name: "agent-instructions".to_string(), + content: agents_content, + format: DocumentFormat::Markdown, + }); + } + + Ok(config) + } + + fn priority(&self) -> u32 { + // Generic agents have lower priority than specific tools + 40 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_agents_adapter_detects_agents_md() { + let temp_dir = TempDir::new().unwrap(); + let agents_path = temp_dir.path().join("AGENTS.md"); + fs::write(&agents_path, "# Agent Instructions").unwrap(); + + let adapter = AgentsAdapter::new(); + assert!(adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_agents_adapter_no_file() { + let temp_dir = TempDir::new().unwrap(); + + let adapter = AgentsAdapter::new(); + assert!(!adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_agents_adapter_reads_agents_md() { + let temp_dir = TempDir::new().unwrap(); + let agents_path = temp_dir.path().join("AGENTS.md"); + let instructions = "# Agent Instructions\nGeneric agent guidelines"; + fs::write(&agents_path, instructions).unwrap(); + + let adapter = AgentsAdapter::new(); + let config = adapter.read_config(temp_dir.path()).unwrap(); + + assert_eq!(config.steering.len(), 1); + assert_eq!(config.steering[0].name, "agent-instructions"); + assert_eq!(config.steering[0].content, instructions); + assert_eq!(config.steering[0].format, DocumentFormat::Markdown); + } + + #[test] + fn test_agents_adapter_priority() { + let adapter = AgentsAdapter::new(); + assert_eq!(adapter.priority(), 40); + } + + #[test] + fn test_agents_adapter_name() { + let adapter = AgentsAdapter::new(); + assert_eq!(adapter.name(), "agents"); + } +} diff --git a/crates/ricecoder-storage/src/industry/aider.rs b/crates/ricecoder-storage/src/industry/aider.rs new file mode 100644 index 00000000..8bbdec57 --- /dev/null +++ b/crates/ricecoder-storage/src/industry/aider.rs @@ -0,0 +1,132 @@ +//! Aider configuration adapter +//! +//! Reads and converts Aider configuration files (.aider.conf.yml) +//! into RiceCoder's internal configuration format. + +use crate::config::{Config, SteeringRule}; +use crate::error::StorageResult; +use crate::types::DocumentFormat; +use std::path::Path; +use tracing::debug; + +use super::adapter::IndustryFileAdapter; + +/// Aider adapter +pub struct AiderAdapter; + +impl AiderAdapter { + /// Create a new Aider adapter + pub fn new() -> Self { + AiderAdapter + } + + /// Read .aider.conf.yml file + fn read_aider_config(&self, project_root: &Path) -> StorageResult> { + let aider_config_path = project_root.join(".aider.conf.yml"); + + if !aider_config_path.exists() { + debug!("No .aider.conf.yml file found at {:?}", aider_config_path); + return Ok(None); + } + + debug!("Reading .aider.conf.yml from {:?}", aider_config_path); + let content = std::fs::read_to_string(&aider_config_path).map_err(|e| { + crate::error::StorageError::io_error( + aider_config_path.clone(), + crate::error::IoOperation::Read, + e, + ) + })?; + + Ok(Some(content)) + } +} + +impl Default for AiderAdapter { + fn default() -> Self { + Self::new() + } +} + +impl IndustryFileAdapter for AiderAdapter { + fn name(&self) -> &'static str { + "aider" + } + + fn can_handle(&self, project_root: &Path) -> bool { + project_root.join(".aider.conf.yml").exists() + } + + fn read_config(&self, project_root: &Path) -> StorageResult { + let mut config = Config::default(); + + if let Ok(Some(aider_config)) = self.read_aider_config(project_root) { + debug!("Adding Aider configuration as steering rule"); + config.steering.push(SteeringRule { + name: "aider-config".to_string(), + content: aider_config, + format: DocumentFormat::Markdown, + }); + } + + Ok(config) + } + + fn priority(&self) -> u32 { + // Aider has medium priority + 50 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_aider_adapter_detects_config() { + let temp_dir = TempDir::new().unwrap(); + let aider_config_path = temp_dir.path().join(".aider.conf.yml"); + fs::write(&aider_config_path, "model: gpt-4").unwrap(); + + let adapter = AiderAdapter::new(); + assert!(adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_aider_adapter_no_file() { + let temp_dir = TempDir::new().unwrap(); + + let adapter = AiderAdapter::new(); + assert!(!adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_aider_adapter_reads_config() { + let temp_dir = TempDir::new().unwrap(); + let aider_config_path = temp_dir.path().join(".aider.conf.yml"); + let config_content = "model: gpt-4\ntemperature: 0.7"; + fs::write(&aider_config_path, config_content).unwrap(); + + let adapter = AiderAdapter::new(); + let config = adapter.read_config(temp_dir.path()).unwrap(); + + assert_eq!(config.steering.len(), 1); + assert_eq!(config.steering[0].name, "aider-config"); + assert_eq!(config.steering[0].content, config_content); + assert_eq!(config.steering[0].format, DocumentFormat::Markdown); + } + + #[test] + fn test_aider_adapter_priority() { + let adapter = AiderAdapter::new(); + assert_eq!(adapter.priority(), 50); + } + + #[test] + fn test_aider_adapter_name() { + let adapter = AiderAdapter::new(); + assert_eq!(adapter.name(), "aider"); + } +} diff --git a/crates/ricecoder-storage/src/industry/claude.rs b/crates/ricecoder-storage/src/industry/claude.rs new file mode 100644 index 00000000..58c1fe2c --- /dev/null +++ b/crates/ricecoder-storage/src/industry/claude.rs @@ -0,0 +1,132 @@ +//! Claude IDE configuration adapter +//! +//! Reads and converts Claude IDE configuration files (CLAUDE.md) +//! into RiceCoder's internal configuration format. + +use crate::config::{Config, SteeringRule}; +use crate::error::StorageResult; +use crate::types::DocumentFormat; +use std::path::Path; +use tracing::debug; + +use super::adapter::IndustryFileAdapter; + +/// Claude IDE adapter +pub struct ClaudeAdapter; + +impl ClaudeAdapter { + /// Create a new Claude adapter + pub fn new() -> Self { + ClaudeAdapter + } + + /// Read CLAUDE.md file + fn read_claude_md(&self, project_root: &Path) -> StorageResult> { + let claude_path = project_root.join("CLAUDE.md"); + + if !claude_path.exists() { + debug!("No CLAUDE.md file found at {:?}", claude_path); + return Ok(None); + } + + debug!("Reading CLAUDE.md from {:?}", claude_path); + let content = std::fs::read_to_string(&claude_path).map_err(|e| { + crate::error::StorageError::io_error( + claude_path.clone(), + crate::error::IoOperation::Read, + e, + ) + })?; + + Ok(Some(content)) + } +} + +impl Default for ClaudeAdapter { + fn default() -> Self { + Self::new() + } +} + +impl IndustryFileAdapter for ClaudeAdapter { + fn name(&self) -> &'static str { + "claude" + } + + fn can_handle(&self, project_root: &Path) -> bool { + project_root.join("CLAUDE.md").exists() + } + + fn read_config(&self, project_root: &Path) -> StorageResult { + let mut config = Config::default(); + + if let Ok(Some(claude_content)) = self.read_claude_md(project_root) { + debug!("Adding Claude instructions as steering rule"); + config.steering.push(SteeringRule { + name: "claude-instructions".to_string(), + content: claude_content, + format: DocumentFormat::Markdown, + }); + } + + Ok(config) + } + + fn priority(&self) -> u32 { + // Claude has medium priority + 50 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_claude_adapter_detects_claude_md() { + let temp_dir = TempDir::new().unwrap(); + let claude_path = temp_dir.path().join("CLAUDE.md"); + fs::write(&claude_path, "# Claude Instructions").unwrap(); + + let adapter = ClaudeAdapter::new(); + assert!(adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_claude_adapter_no_file() { + let temp_dir = TempDir::new().unwrap(); + + let adapter = ClaudeAdapter::new(); + assert!(!adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_claude_adapter_reads_claude_md() { + let temp_dir = TempDir::new().unwrap(); + let claude_path = temp_dir.path().join("CLAUDE.md"); + let instructions = "# Claude Instructions\nBe helpful and thorough"; + fs::write(&claude_path, instructions).unwrap(); + + let adapter = ClaudeAdapter::new(); + let config = adapter.read_config(temp_dir.path()).unwrap(); + + assert_eq!(config.steering.len(), 1); + assert_eq!(config.steering[0].name, "claude-instructions"); + assert_eq!(config.steering[0].content, instructions); + assert_eq!(config.steering[0].format, DocumentFormat::Markdown); + } + + #[test] + fn test_claude_adapter_priority() { + let adapter = ClaudeAdapter::new(); + assert_eq!(adapter.priority(), 50); + } + + #[test] + fn test_claude_adapter_name() { + let adapter = ClaudeAdapter::new(); + assert_eq!(adapter.name(), "claude"); + } +} diff --git a/crates/ricecoder-storage/src/industry/cline.rs b/crates/ricecoder-storage/src/industry/cline.rs new file mode 100644 index 00000000..ce8df3c6 --- /dev/null +++ b/crates/ricecoder-storage/src/industry/cline.rs @@ -0,0 +1,132 @@ +//! Cline IDE configuration adapter +//! +//! Reads and converts Cline IDE configuration files (.clinerules) +//! into RiceCoder's internal configuration format. + +use crate::config::{Config, SteeringRule}; +use crate::error::StorageResult; +use crate::types::DocumentFormat; +use std::path::Path; +use tracing::debug; + +use super::adapter::IndustryFileAdapter; + +/// Cline IDE adapter +pub struct ClineAdapter; + +impl ClineAdapter { + /// Create a new Cline adapter + pub fn new() -> Self { + ClineAdapter + } + + /// Read .clinerules file + fn read_clinerules(&self, project_root: &Path) -> StorageResult> { + let clinerules_path = project_root.join(".clinerules"); + + if !clinerules_path.exists() { + debug!("No .clinerules file found at {:?}", clinerules_path); + return Ok(None); + } + + debug!("Reading .clinerules from {:?}", clinerules_path); + let content = std::fs::read_to_string(&clinerules_path).map_err(|e| { + crate::error::StorageError::io_error( + clinerules_path.clone(), + crate::error::IoOperation::Read, + e, + ) + })?; + + Ok(Some(content)) + } +} + +impl Default for ClineAdapter { + fn default() -> Self { + Self::new() + } +} + +impl IndustryFileAdapter for ClineAdapter { + fn name(&self) -> &'static str { + "cline" + } + + fn can_handle(&self, project_root: &Path) -> bool { + project_root.join(".clinerules").exists() + } + + fn read_config(&self, project_root: &Path) -> StorageResult { + let mut config = Config::default(); + + if let Ok(Some(rules_content)) = self.read_clinerules(project_root) { + debug!("Adding Cline rules as steering rule"); + config.steering.push(SteeringRule { + name: "cline-rules".to_string(), + content: rules_content, + format: DocumentFormat::Markdown, + }); + } + + Ok(config) + } + + fn priority(&self) -> u32 { + // Cline has medium priority + 50 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_cline_adapter_detects_clinerules() { + let temp_dir = TempDir::new().unwrap(); + let clinerules_path = temp_dir.path().join(".clinerules"); + fs::write(&clinerules_path, "# Cline rules").unwrap(); + + let adapter = ClineAdapter::new(); + assert!(adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_cline_adapter_no_file() { + let temp_dir = TempDir::new().unwrap(); + + let adapter = ClineAdapter::new(); + assert!(!adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_cline_adapter_reads_clinerules() { + let temp_dir = TempDir::new().unwrap(); + let clinerules_path = temp_dir.path().join(".clinerules"); + let rules = "# Cline Rules\nBe efficient"; + fs::write(&clinerules_path, rules).unwrap(); + + let adapter = ClineAdapter::new(); + let config = adapter.read_config(temp_dir.path()).unwrap(); + + assert_eq!(config.steering.len(), 1); + assert_eq!(config.steering[0].name, "cline-rules"); + assert_eq!(config.steering[0].content, rules); + assert_eq!(config.steering[0].format, DocumentFormat::Markdown); + } + + #[test] + fn test_cline_adapter_priority() { + let adapter = ClineAdapter::new(); + assert_eq!(adapter.priority(), 50); + } + + #[test] + fn test_cline_adapter_name() { + let adapter = ClineAdapter::new(); + assert_eq!(adapter.name(), "cline"); + } +} diff --git a/crates/ricecoder-storage/src/industry/continue_dev.rs b/crates/ricecoder-storage/src/industry/continue_dev.rs new file mode 100644 index 00000000..dd331a64 --- /dev/null +++ b/crates/ricecoder-storage/src/industry/continue_dev.rs @@ -0,0 +1,178 @@ +//! Continue.dev configuration adapter +//! +//! Reads and converts Continue.dev configuration files (.continue/ directory) +//! into RiceCoder's internal configuration format. + +use crate::config::{Config, SteeringRule}; +use crate::error::StorageResult; +use crate::types::DocumentFormat; +use std::path::Path; +use tracing::debug; + +use super::adapter::IndustryFileAdapter; + +/// Continue.dev adapter +pub struct ContinueDevAdapter; + +impl ContinueDevAdapter { + /// Create a new Continue.dev adapter + pub fn new() -> Self { + ContinueDevAdapter + } + + /// Read .continue/ directory configuration + fn read_continue_config(&self, project_root: &Path) -> StorageResult> { + let continue_dir = project_root.join(".continue"); + + if !continue_dir.exists() { + debug!("No .continue directory found at {:?}", continue_dir); + return Ok(None); + } + + debug!("Reading .continue configuration from {:?}", continue_dir); + + // Try to read config.json or other config files + let config_path = continue_dir.join("config.json"); + if config_path.exists() { + let content = std::fs::read_to_string(&config_path).map_err(|e| { + crate::error::StorageError::io_error( + config_path.clone(), + crate::error::IoOperation::Read, + e, + ) + })?; + return Ok(Some(content)); + } + + // If no config.json, try to read all files in the directory + let mut combined_content = String::new(); + if let Ok(entries) = std::fs::read_dir(&continue_dir) { + for entry in entries.flatten() { + if let Ok(metadata) = entry.metadata() { + if metadata.is_file() { + if let Ok(content) = std::fs::read_to_string(entry.path()) { + combined_content.push_str(&format!( + "# {}\n{}\n\n", + entry.path().display(), + content + )); + } + } + } + } + } + + if combined_content.is_empty() { + Ok(None) + } else { + Ok(Some(combined_content)) + } + } +} + +impl Default for ContinueDevAdapter { + fn default() -> Self { + Self::new() + } +} + +impl IndustryFileAdapter for ContinueDevAdapter { + fn name(&self) -> &'static str { + "continue" + } + + fn can_handle(&self, project_root: &Path) -> bool { + project_root.join(".continue").exists() + } + + fn read_config(&self, project_root: &Path) -> StorageResult { + let mut config = Config::default(); + + if let Ok(Some(continue_config)) = self.read_continue_config(project_root) { + debug!("Adding Continue.dev configuration as steering rule"); + config.steering.push(SteeringRule { + name: "continue-config".to_string(), + content: continue_config, + format: DocumentFormat::Markdown, + }); + } + + Ok(config) + } + + fn priority(&self) -> u32 { + // Continue.dev has medium priority + 50 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_continue_adapter_detects_directory() { + let temp_dir = TempDir::new().unwrap(); + let continue_dir = temp_dir.path().join(".continue"); + fs::create_dir(&continue_dir).unwrap(); + + let adapter = ContinueDevAdapter::new(); + assert!(adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_continue_adapter_no_directory() { + let temp_dir = TempDir::new().unwrap(); + + let adapter = ContinueDevAdapter::new(); + assert!(!adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_continue_adapter_reads_config_json() { + let temp_dir = TempDir::new().unwrap(); + let continue_dir = temp_dir.path().join(".continue"); + fs::create_dir(&continue_dir).unwrap(); + let config_path = continue_dir.join("config.json"); + let config_content = r#"{"models": ["gpt-4"]}"#; + fs::write(&config_path, config_content).unwrap(); + + let adapter = ContinueDevAdapter::new(); + let config = adapter.read_config(temp_dir.path()).unwrap(); + + assert_eq!(config.steering.len(), 1); + assert_eq!(config.steering[0].name, "continue-config"); + assert_eq!(config.steering[0].content, config_content); + } + + #[test] + fn test_continue_adapter_reads_multiple_files() { + let temp_dir = TempDir::new().unwrap(); + let continue_dir = temp_dir.path().join(".continue"); + fs::create_dir(&continue_dir).unwrap(); + fs::write(continue_dir.join("file1.txt"), "content1").unwrap(); + fs::write(continue_dir.join("file2.txt"), "content2").unwrap(); + + let adapter = ContinueDevAdapter::new(); + let config = adapter.read_config(temp_dir.path()).unwrap(); + + assert_eq!(config.steering.len(), 1); + assert_eq!(config.steering[0].name, "continue-config"); + assert!(config.steering[0].content.contains("content1")); + assert!(config.steering[0].content.contains("content2")); + } + + #[test] + fn test_continue_adapter_priority() { + let adapter = ContinueDevAdapter::new(); + assert_eq!(adapter.priority(), 50); + } + + #[test] + fn test_continue_adapter_name() { + let adapter = ContinueDevAdapter::new(); + assert_eq!(adapter.name(), "continue"); + } +} diff --git a/crates/ricecoder-storage/src/industry/copilot.rs b/crates/ricecoder-storage/src/industry/copilot.rs new file mode 100644 index 00000000..783b737e --- /dev/null +++ b/crates/ricecoder-storage/src/industry/copilot.rs @@ -0,0 +1,139 @@ +//! GitHub Copilot configuration adapter +//! +//! Reads and converts GitHub Copilot configuration files (.github/copilot-instructions.md) +//! into RiceCoder's internal configuration format. + +use crate::config::{Config, SteeringRule}; +use crate::error::StorageResult; +use crate::types::DocumentFormat; +use std::path::Path; +use tracing::debug; + +use super::adapter::IndustryFileAdapter; + +/// GitHub Copilot adapter +pub struct CopilotAdapter; + +impl CopilotAdapter { + /// Create a new Copilot adapter + pub fn new() -> Self { + CopilotAdapter + } + + /// Read .github/copilot-instructions.md file + fn read_copilot_instructions(&self, project_root: &Path) -> StorageResult> { + let copilot_path = project_root.join(".github/copilot-instructions.md"); + + if !copilot_path.exists() { + debug!( + "No .github/copilot-instructions.md file found at {:?}", + copilot_path + ); + return Ok(None); + } + + debug!("Reading .github/copilot-instructions.md from {:?}", copilot_path); + let content = std::fs::read_to_string(&copilot_path).map_err(|e| { + crate::error::StorageError::io_error( + copilot_path.clone(), + crate::error::IoOperation::Read, + e, + ) + })?; + + Ok(Some(content)) + } +} + +impl Default for CopilotAdapter { + fn default() -> Self { + Self::new() + } +} + +impl IndustryFileAdapter for CopilotAdapter { + fn name(&self) -> &'static str { + "copilot" + } + + fn can_handle(&self, project_root: &Path) -> bool { + project_root.join(".github/copilot-instructions.md").exists() + } + + fn read_config(&self, project_root: &Path) -> StorageResult { + let mut config = Config::default(); + + if let Ok(Some(instructions)) = self.read_copilot_instructions(project_root) { + debug!("Adding Copilot instructions as steering rule"); + config.steering.push(SteeringRule { + name: "copilot-instructions".to_string(), + content: instructions, + format: DocumentFormat::Markdown, + }); + } + + Ok(config) + } + + fn priority(&self) -> u32 { + // Copilot has medium priority + 50 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_copilot_adapter_detects_instructions() { + let temp_dir = TempDir::new().unwrap(); + let github_dir = temp_dir.path().join(".github"); + fs::create_dir(&github_dir).unwrap(); + let copilot_path = github_dir.join("copilot-instructions.md"); + fs::write(&copilot_path, "# Copilot Instructions").unwrap(); + + let adapter = CopilotAdapter::new(); + assert!(adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_copilot_adapter_no_file() { + let temp_dir = TempDir::new().unwrap(); + + let adapter = CopilotAdapter::new(); + assert!(!adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_copilot_adapter_reads_instructions() { + let temp_dir = TempDir::new().unwrap(); + let github_dir = temp_dir.path().join(".github"); + fs::create_dir(&github_dir).unwrap(); + let copilot_path = github_dir.join("copilot-instructions.md"); + let instructions = "# Copilot Instructions\nBe helpful"; + fs::write(&copilot_path, instructions).unwrap(); + + let adapter = CopilotAdapter::new(); + let config = adapter.read_config(temp_dir.path()).unwrap(); + + assert_eq!(config.steering.len(), 1); + assert_eq!(config.steering[0].name, "copilot-instructions"); + assert_eq!(config.steering[0].content, instructions); + assert_eq!(config.steering[0].format, DocumentFormat::Markdown); + } + + #[test] + fn test_copilot_adapter_priority() { + let adapter = CopilotAdapter::new(); + assert_eq!(adapter.priority(), 50); + } + + #[test] + fn test_copilot_adapter_name() { + let adapter = CopilotAdapter::new(); + assert_eq!(adapter.name(), "copilot"); + } +} diff --git a/crates/ricecoder-storage/src/industry/cursor.rs b/crates/ricecoder-storage/src/industry/cursor.rs new file mode 100644 index 00000000..abf4a684 --- /dev/null +++ b/crates/ricecoder-storage/src/industry/cursor.rs @@ -0,0 +1,245 @@ +//! Cursor IDE configuration adapter +//! +//! Reads and converts Cursor IDE configuration files (.cursorrules and .cursor/ directory) +//! into RiceCoder's internal configuration format. + +use crate::config::{Config, SteeringRule}; +use crate::error::StorageResult; +use crate::types::DocumentFormat; +use std::path::Path; +use tracing::{debug, warn}; + +use super::adapter::IndustryFileAdapter; + +/// Cursor IDE adapter +pub struct CursorAdapter; + +impl CursorAdapter { + /// Create a new Cursor adapter + pub fn new() -> Self { + CursorAdapter + } + + /// Read .cursorrules file + fn read_cursorrules(&self, project_root: &Path) -> StorageResult> { + let cursorrules_path = project_root.join(".cursorrules"); + + if !cursorrules_path.exists() { + debug!("No .cursorrules file found at {:?}", cursorrules_path); + return Ok(None); + } + + debug!("Reading .cursorrules from {:?}", cursorrules_path); + let content = std::fs::read_to_string(&cursorrules_path).map_err(|e| { + crate::error::StorageError::io_error( + cursorrules_path.clone(), + crate::error::IoOperation::Read, + e, + ) + })?; + + Ok(Some(content)) + } + + /// Read .cursor/ directory settings + fn read_cursor_settings(&self, project_root: &Path) -> StorageResult> { + let cursor_dir = project_root.join(".cursor"); + + if !cursor_dir.exists() { + debug!("No .cursor directory found at {:?}", cursor_dir); + return Ok(None); + } + + debug!("Reading .cursor settings from {:?}", cursor_dir); + + // Try to read settings.json or other config files in .cursor/ + let settings_path = cursor_dir.join("settings.json"); + if settings_path.exists() { + let content = std::fs::read_to_string(&settings_path).map_err(|e| { + crate::error::StorageError::io_error( + settings_path.clone(), + crate::error::IoOperation::Read, + e, + ) + })?; + return Ok(Some(content)); + } + + // If no settings.json, try to read all files in the directory + let mut combined_content = String::new(); + if let Ok(entries) = std::fs::read_dir(&cursor_dir) { + for entry in entries.flatten() { + if let Ok(metadata) = entry.metadata() { + if metadata.is_file() { + if let Ok(content) = std::fs::read_to_string(entry.path()) { + combined_content.push_str(&format!( + "# {}\n{}\n\n", + entry.path().display(), + content + )); + } + } + } + } + } + + if combined_content.is_empty() { + Ok(None) + } else { + Ok(Some(combined_content)) + } + } +} + +impl Default for CursorAdapter { + fn default() -> Self { + Self::new() + } +} + +impl IndustryFileAdapter for CursorAdapter { + fn name(&self) -> &'static str { + "cursor" + } + + fn can_handle(&self, project_root: &Path) -> bool { + let cursorrules_exists = project_root.join(".cursorrules").exists(); + let cursor_dir_exists = project_root.join(".cursor").exists(); + + cursorrules_exists || cursor_dir_exists + } + + fn read_config(&self, project_root: &Path) -> StorageResult { + let mut config = Config::default(); + + // Read .cursorrules + if let Ok(Some(cursorrules_content)) = self.read_cursorrules(project_root) { + debug!("Adding Cursor rules as steering rule"); + config.steering.push(SteeringRule { + name: "cursor-rules".to_string(), + content: cursorrules_content, + format: DocumentFormat::Markdown, + }); + } + + // Read .cursor/ settings + if let Ok(Some(cursor_settings)) = self.read_cursor_settings(project_root) { + debug!("Adding Cursor settings as steering rule"); + config.steering.push(SteeringRule { + name: "cursor-settings".to_string(), + content: cursor_settings, + format: DocumentFormat::Markdown, + }); + } + + if config.steering.is_empty() { + warn!("Cursor adapter found files but no content was read"); + } + + Ok(config) + } + + fn priority(&self) -> u32 { + // Cursor has medium priority (after project-specific, before generic) + 50 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_cursor_adapter_detects_cursorrules() { + let temp_dir = TempDir::new().unwrap(); + let cursorrules_path = temp_dir.path().join(".cursorrules"); + fs::write(&cursorrules_path, "# Cursor rules").unwrap(); + + let adapter = CursorAdapter::new(); + assert!(adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_cursor_adapter_detects_cursor_dir() { + let temp_dir = TempDir::new().unwrap(); + let cursor_dir = temp_dir.path().join(".cursor"); + fs::create_dir(&cursor_dir).unwrap(); + + let adapter = CursorAdapter::new(); + assert!(adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_cursor_adapter_no_files() { + let temp_dir = TempDir::new().unwrap(); + + let adapter = CursorAdapter::new(); + assert!(!adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_cursor_adapter_reads_cursorrules() { + let temp_dir = TempDir::new().unwrap(); + let cursorrules_path = temp_dir.path().join(".cursorrules"); + let rules_content = "# Cursor rules\nBe helpful"; + fs::write(&cursorrules_path, rules_content).unwrap(); + + let adapter = CursorAdapter::new(); + let config = adapter.read_config(temp_dir.path()).unwrap(); + + assert_eq!(config.steering.len(), 1); + assert_eq!(config.steering[0].name, "cursor-rules"); + assert_eq!(config.steering[0].content, rules_content); + assert_eq!(config.steering[0].format, DocumentFormat::Markdown); + } + + #[test] + fn test_cursor_adapter_reads_cursor_settings() { + let temp_dir = TempDir::new().unwrap(); + let cursor_dir = temp_dir.path().join(".cursor"); + fs::create_dir(&cursor_dir).unwrap(); + let settings_path = cursor_dir.join("settings.json"); + let settings_content = r#"{"key": "value"}"#; + fs::write(&settings_path, settings_content).unwrap(); + + let adapter = CursorAdapter::new(); + let config = adapter.read_config(temp_dir.path()).unwrap(); + + assert_eq!(config.steering.len(), 1); + assert_eq!(config.steering[0].name, "cursor-settings"); + assert_eq!(config.steering[0].content, settings_content); + } + + #[test] + fn test_cursor_adapter_reads_both() { + let temp_dir = TempDir::new().unwrap(); + let cursorrules_path = temp_dir.path().join(".cursorrules"); + fs::write(&cursorrules_path, "# Rules").unwrap(); + + let cursor_dir = temp_dir.path().join(".cursor"); + fs::create_dir(&cursor_dir).unwrap(); + let settings_path = cursor_dir.join("settings.json"); + fs::write(&settings_path, r#"{"key": "value"}"#).unwrap(); + + let adapter = CursorAdapter::new(); + let config = adapter.read_config(temp_dir.path()).unwrap(); + + assert_eq!(config.steering.len(), 2); + assert_eq!(config.steering[0].name, "cursor-rules"); + assert_eq!(config.steering[1].name, "cursor-settings"); + } + + #[test] + fn test_cursor_adapter_priority() { + let adapter = CursorAdapter::new(); + assert_eq!(adapter.priority(), 50); + } + + #[test] + fn test_cursor_adapter_name() { + let adapter = CursorAdapter::new(); + assert_eq!(adapter.name(), "cursor"); + } +} diff --git a/crates/ricecoder-storage/src/industry/kiro.rs b/crates/ricecoder-storage/src/industry/kiro.rs new file mode 100644 index 00000000..9f1689cb --- /dev/null +++ b/crates/ricecoder-storage/src/industry/kiro.rs @@ -0,0 +1,190 @@ +//! Kiro configuration adapter +//! +//! Reads and converts Kiro configuration files (.kiro/ directory) +//! into RiceCoder's internal configuration format. + +use crate::config::{Config, SteeringRule}; +use crate::error::StorageResult; +use crate::types::DocumentFormat; +use std::path::Path; +use tracing::debug; + +use super::adapter::IndustryFileAdapter; + +/// Kiro adapter +pub struct KiroAdapter; + +impl KiroAdapter { + /// Create a new Kiro adapter + pub fn new() -> Self { + KiroAdapter + } + + /// Read .kiro/ directory configuration + fn read_kiro_config(&self, project_root: &Path) -> StorageResult> { + let kiro_dir = project_root.join(".kiro"); + + if !kiro_dir.exists() { + debug!("No .kiro directory found at {:?}", kiro_dir); + return Ok(None); + } + + debug!("Reading .kiro configuration from {:?}", kiro_dir); + + // Try to read specs and steering files + let mut combined_content = String::new(); + + // Read specs + let specs_dir = kiro_dir.join("specs"); + if specs_dir.exists() { + if let Ok(entries) = std::fs::read_dir(&specs_dir) { + for entry in entries.flatten() { + if let Ok(metadata) = entry.metadata() { + if metadata.is_file() { + if let Ok(content) = std::fs::read_to_string(entry.path()) { + combined_content.push_str(&format!( + "# Spec: {}\n{}\n\n", + entry.path().display(), + content + )); + } + } + } + } + } + } + + // Read steering + let steering_dir = kiro_dir.join("steering"); + if steering_dir.exists() { + if let Ok(entries) = std::fs::read_dir(&steering_dir) { + for entry in entries.flatten() { + if let Ok(metadata) = entry.metadata() { + if metadata.is_file() { + if let Ok(content) = std::fs::read_to_string(entry.path()) { + combined_content.push_str(&format!( + "# Steering: {}\n{}\n\n", + entry.path().display(), + content + )); + } + } + } + } + } + } + + if combined_content.is_empty() { + Ok(None) + } else { + Ok(Some(combined_content)) + } + } +} + +impl Default for KiroAdapter { + fn default() -> Self { + Self::new() + } +} + +impl IndustryFileAdapter for KiroAdapter { + fn name(&self) -> &'static str { + "kiro" + } + + fn can_handle(&self, project_root: &Path) -> bool { + project_root.join(".kiro").exists() + } + + fn read_config(&self, project_root: &Path) -> StorageResult { + let mut config = Config::default(); + + if let Ok(Some(kiro_config)) = self.read_kiro_config(project_root) { + debug!("Adding Kiro configuration as steering rule"); + config.steering.push(SteeringRule { + name: "kiro-config".to_string(), + content: kiro_config, + format: DocumentFormat::Markdown, + }); + } + + Ok(config) + } + + fn priority(&self) -> u32 { + // Kiro has highest priority among industry files (it's the native format) + 100 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_kiro_adapter_detects_directory() { + let temp_dir = TempDir::new().unwrap(); + let kiro_dir = temp_dir.path().join(".kiro"); + fs::create_dir(&kiro_dir).unwrap(); + + let adapter = KiroAdapter::new(); + assert!(adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_kiro_adapter_no_directory() { + let temp_dir = TempDir::new().unwrap(); + + let adapter = KiroAdapter::new(); + assert!(!adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_kiro_adapter_reads_specs() { + let temp_dir = TempDir::new().unwrap(); + let kiro_dir = temp_dir.path().join(".kiro"); + fs::create_dir(&kiro_dir).unwrap(); + let specs_dir = kiro_dir.join("specs"); + fs::create_dir(&specs_dir).unwrap(); + fs::write(specs_dir.join("spec1.md"), "# Spec 1").unwrap(); + + let adapter = KiroAdapter::new(); + let config = adapter.read_config(temp_dir.path()).unwrap(); + + assert_eq!(config.steering.len(), 1); + assert_eq!(config.steering[0].name, "kiro-config"); + assert!(config.steering[0].content.contains("Spec 1")); + } + + #[test] + fn test_kiro_adapter_reads_steering() { + let temp_dir = TempDir::new().unwrap(); + let kiro_dir = temp_dir.path().join(".kiro"); + fs::create_dir(&kiro_dir).unwrap(); + let steering_dir = kiro_dir.join("steering"); + fs::create_dir(&steering_dir).unwrap(); + fs::write(steering_dir.join("rules.md"), "# Rules").unwrap(); + + let adapter = KiroAdapter::new(); + let config = adapter.read_config(temp_dir.path()).unwrap(); + + assert_eq!(config.steering.len(), 1); + assert_eq!(config.steering[0].name, "kiro-config"); + assert!(config.steering[0].content.contains("Rules")); + } + + #[test] + fn test_kiro_adapter_priority() { + let adapter = KiroAdapter::new(); + assert_eq!(adapter.priority(), 100); + } + + #[test] + fn test_kiro_adapter_name() { + let adapter = KiroAdapter::new(); + assert_eq!(adapter.name(), "kiro"); + } +} diff --git a/crates/ricecoder-storage/src/industry/mod.rs b/crates/ricecoder-storage/src/industry/mod.rs new file mode 100644 index 00000000..ab527eab --- /dev/null +++ b/crates/ricecoder-storage/src/industry/mod.rs @@ -0,0 +1,42 @@ +//! Industry-standard file support +//! +//! This module provides support for reading and converting configuration files +//! from other AI coding tools (Cursor, Claude, Windsurf, Cline, Aider, Copilot, Continue, Kiro) +//! into RiceCoder's internal configuration format. +//! +//! The module follows a precedence order when multiple industry files exist: +//! environment > project > legacy > global > defaults + +pub mod adapter; +pub mod agents; +pub mod aider; +pub mod claude; +pub mod cline; +pub mod continue_dev; +pub mod copilot; +pub mod cursor; +pub mod kiro; +pub mod windsurf; + +// Re-export commonly used types +pub use adapter::{IndustryFileAdapter, IndustryFileDetector, FileDetectionResult}; +pub use agents::AgentsAdapter; +pub use aider::AiderAdapter; +pub use claude::ClaudeAdapter; +pub use cline::ClineAdapter; +pub use continue_dev::ContinueDevAdapter; +pub use copilot::CopilotAdapter; +pub use cursor::CursorAdapter; +pub use kiro::KiroAdapter; +pub use windsurf::WindsurfAdapter; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_industry_module_exports() { + // Verify that the module exports are accessible + let _: &dyn IndustryFileAdapter; + } +} diff --git a/crates/ricecoder-storage/src/industry/windsurf.rs b/crates/ricecoder-storage/src/industry/windsurf.rs new file mode 100644 index 00000000..85ad6f52 --- /dev/null +++ b/crates/ricecoder-storage/src/industry/windsurf.rs @@ -0,0 +1,132 @@ +//! Windsurf IDE configuration adapter +//! +//! Reads and converts Windsurf IDE configuration files (.windsurfrules) +//! into RiceCoder's internal configuration format. + +use crate::config::{Config, SteeringRule}; +use crate::error::StorageResult; +use crate::types::DocumentFormat; +use std::path::Path; +use tracing::debug; + +use super::adapter::IndustryFileAdapter; + +/// Windsurf IDE adapter +pub struct WindsurfAdapter; + +impl WindsurfAdapter { + /// Create a new Windsurf adapter + pub fn new() -> Self { + WindsurfAdapter + } + + /// Read .windsurfrules file + fn read_windsurfrules(&self, project_root: &Path) -> StorageResult> { + let windsurfrules_path = project_root.join(".windsurfrules"); + + if !windsurfrules_path.exists() { + debug!("No .windsurfrules file found at {:?}", windsurfrules_path); + return Ok(None); + } + + debug!("Reading .windsurfrules from {:?}", windsurfrules_path); + let content = std::fs::read_to_string(&windsurfrules_path).map_err(|e| { + crate::error::StorageError::io_error( + windsurfrules_path.clone(), + crate::error::IoOperation::Read, + e, + ) + })?; + + Ok(Some(content)) + } +} + +impl Default for WindsurfAdapter { + fn default() -> Self { + Self::new() + } +} + +impl IndustryFileAdapter for WindsurfAdapter { + fn name(&self) -> &'static str { + "windsurf" + } + + fn can_handle(&self, project_root: &Path) -> bool { + project_root.join(".windsurfrules").exists() + } + + fn read_config(&self, project_root: &Path) -> StorageResult { + let mut config = Config::default(); + + if let Ok(Some(rules_content)) = self.read_windsurfrules(project_root) { + debug!("Adding Windsurf rules as steering rule"); + config.steering.push(SteeringRule { + name: "windsurf-rules".to_string(), + content: rules_content, + format: DocumentFormat::Markdown, + }); + } + + Ok(config) + } + + fn priority(&self) -> u32 { + // Windsurf has medium priority + 50 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_windsurf_adapter_detects_windsurfrules() { + let temp_dir = TempDir::new().unwrap(); + let windsurfrules_path = temp_dir.path().join(".windsurfrules"); + fs::write(&windsurfrules_path, "# Windsurf rules").unwrap(); + + let adapter = WindsurfAdapter::new(); + assert!(adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_windsurf_adapter_no_file() { + let temp_dir = TempDir::new().unwrap(); + + let adapter = WindsurfAdapter::new(); + assert!(!adapter.can_handle(temp_dir.path())); + } + + #[test] + fn test_windsurf_adapter_reads_windsurfrules() { + let temp_dir = TempDir::new().unwrap(); + let windsurfrules_path = temp_dir.path().join(".windsurfrules"); + let rules = "# Windsurf Rules\nBe productive"; + fs::write(&windsurfrules_path, rules).unwrap(); + + let adapter = WindsurfAdapter::new(); + let config = adapter.read_config(temp_dir.path()).unwrap(); + + assert_eq!(config.steering.len(), 1); + assert_eq!(config.steering[0].name, "windsurf-rules"); + assert_eq!(config.steering[0].content, rules); + assert_eq!(config.steering[0].format, DocumentFormat::Markdown); + } + + #[test] + fn test_windsurf_adapter_priority() { + let adapter = WindsurfAdapter::new(); + assert_eq!(adapter.priority(), 50); + } + + #[test] + fn test_windsurf_adapter_name() { + let adapter = WindsurfAdapter::new(); + assert_eq!(adapter.name(), "windsurf"); + } +} diff --git a/crates/ricecoder-storage/src/lib.rs b/crates/ricecoder-storage/src/lib.rs index 6b3e96e4..2959f14f 100644 --- a/crates/ricecoder-storage/src/lib.rs +++ b/crates/ricecoder-storage/src/lib.rs @@ -4,17 +4,27 @@ //! including global and project-local knowledge bases, configuration loading, //! and data persistence. +pub mod cache; +pub mod config; pub mod error; pub mod first_run; pub mod global_store; +pub mod industry; pub mod manager; +pub mod offline; pub mod project_store; +pub mod relocation; pub mod types; // Re-export commonly used types +pub use cache::{CacheManager, CacheEntry, CacheInvalidationStrategy}; +pub use config::{Config, ConfigLoader, DocumentLoader, EnvOverrides, ConfigMerger, StorageModeHandler}; pub use error::{IoOperation, StorageError, StorageResult}; pub use first_run::FirstRunHandler; pub use global_store::GlobalStore; +pub use industry::{IndustryFileAdapter, IndustryFileDetector, FileDetectionResult}; pub use manager::{PathResolver, StorageManager}; +pub use offline::OfflineModeHandler; pub use project_store::ProjectStore; +pub use relocation::RelocationService; pub use types::{ConfigFormat, DocumentFormat, ResourceType, StorageConfig, StorageMode, StorageState}; diff --git a/crates/ricecoder-storage/src/manager.rs b/crates/ricecoder-storage/src/manager.rs index 58e95340..392a3c85 100644 --- a/crates/ricecoder-storage/src/manager.rs +++ b/crates/ricecoder-storage/src/manager.rs @@ -2,7 +2,7 @@ use crate::error::{StorageError, StorageResult}; use crate::types::{ResourceType, StorageMode}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// Storage manager trait for managing storage operations pub trait StorageManager: Send + Sync { @@ -65,7 +65,7 @@ impl PathResolver { } /// Expand ~ in paths to home directory - pub fn expand_home(path: &PathBuf) -> StorageResult { + pub fn expand_home(path: &Path) -> StorageResult { let path_str = path .to_str() .ok_or_else(|| StorageError::path_resolution_error("Invalid path encoding"))?; @@ -81,7 +81,7 @@ impl PathResolver { } } - Ok(path.clone()) + Ok(path.to_path_buf()) } } diff --git a/crates/ricecoder-storage/src/offline.rs b/crates/ricecoder-storage/src/offline.rs new file mode 100644 index 00000000..a2e43a4b --- /dev/null +++ b/crates/ricecoder-storage/src/offline.rs @@ -0,0 +1,304 @@ +//! Offline mode handling for storage +//! +//! Provides functionality to detect when storage is unavailable and operate +//! in read-only mode with cached data. + +use crate::error::{StorageError, StorageResult}; +use crate::types::StorageState; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; +use tracing::{debug, warn}; + +/// Offline mode handler +pub struct OfflineModeHandler; + +impl OfflineModeHandler { + /// Check if storage is available + /// + /// # Arguments + /// + /// * `storage_path` - Path to storage directory + /// + /// # Returns + /// + /// Returns the storage state (Available, Unavailable, or ReadOnly) + pub fn check_storage_availability(storage_path: &Path) -> StorageState { + // Check if path exists + if !storage_path.exists() { + warn!( + "Storage unavailable: path does not exist: {}", + storage_path.display() + ); + return StorageState::Unavailable { + reason: "Storage path does not exist".to_string(), + }; + } + + // Check if path is accessible (try to read directory) + match std::fs::read_dir(storage_path) { + Ok(_) => { + debug!("Storage is available: {}", storage_path.display()); + StorageState::Available + } + Err(e) => { + warn!( + "Storage unavailable: cannot read directory {}: {}", + storage_path.display(), + e + ); + StorageState::Unavailable { + reason: format!("Cannot read directory: {}", e), + } + } + } + } + + /// Check if storage is on external or network drive + /// + /// # Arguments + /// + /// * `storage_path` - Path to storage directory + /// + /// # Returns + /// + /// Returns true if storage appears to be on external/network storage + pub fn is_external_storage(storage_path: &Path) -> bool { + let path_str = storage_path.to_string_lossy(); + + // Check for common network/external indicators + #[cfg(target_os = "windows")] + { + // Check for UNC paths (network drives) + if path_str.starts_with("\\\\") { + return true; + } + // Check for mapped drives (typically Z:, Y:, etc.) + if let Some(drive) = path_str.chars().next() { + if drive.is_alphabetic() { + let drive_letter = drive.to_ascii_uppercase(); + // Assume drives beyond D: might be external/network + if drive_letter > 'D' { + return true; + } + } + } + } + + #[cfg(target_os = "macos")] + { + // Check for mounted volumes + if path_str.starts_with("/Volumes/") { + return true; + } + } + + #[cfg(target_os = "linux")] + { + // Check for mounted filesystems + if path_str.starts_with("/mnt/") || path_str.starts_with("/media/") { + return true; + } + } + + false + } + + /// Transition to offline mode + /// + /// # Arguments + /// + /// * `storage_path` - Path to storage directory + /// * `cache_available` - Whether cached data is available + /// + /// # Returns + /// + /// Returns the new storage state + pub fn enter_offline_mode(storage_path: &Path, cache_available: bool) -> StorageState { + let cached_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_string(); + + if cache_available { + warn!( + "Entering offline mode for storage: {}. Using cached data.", + storage_path.display() + ); + StorageState::ReadOnly { cached_at } + } else { + warn!( + "Entering offline mode for storage: {}. No cached data available.", + storage_path.display() + ); + StorageState::Unavailable { + reason: "Storage unavailable and no cached data available".to_string(), + } + } + } + + /// Check if we should retry storage access + /// + /// # Arguments + /// + /// * `storage_path` - Path to storage directory + /// + /// # Returns + /// + /// Returns true if storage is now available + pub fn retry_storage_access(storage_path: &Path) -> bool { + match Self::check_storage_availability(storage_path) { + StorageState::Available => { + debug!("Storage is now available: {}", storage_path.display()); + true + } + _ => { + debug!("Storage is still unavailable: {}", storage_path.display()); + false + } + } + } + + /// Log offline mode warning + /// + /// # Arguments + /// + /// * `storage_path` - Path to storage directory + /// * `reason` - Reason for offline mode + pub fn log_offline_warning(storage_path: &Path, reason: &str) { + warn!( + "Storage offline mode activated for {}: {}", + storage_path.display(), + reason + ); + } + + /// Validate that we can operate in offline mode + /// + /// # Arguments + /// + /// * `cache_available` - Whether cached data is available + /// + /// # Returns + /// + /// Returns error if offline mode cannot be used + pub fn validate_offline_mode(cache_available: bool) -> StorageResult<()> { + if !cache_available { + return Err(StorageError::internal( + "Cannot enter offline mode: no cached data available", + )); + } + + debug!("Offline mode validation passed"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_check_storage_availability_exists() { + let temp_dir = TempDir::new().unwrap(); + let state = OfflineModeHandler::check_storage_availability(temp_dir.path()); + + assert_eq!(state, StorageState::Available); + } + + #[test] + fn test_check_storage_availability_not_exists() { + let path = std::path::PathBuf::from("/nonexistent/path/that/does/not/exist"); + let state = OfflineModeHandler::check_storage_availability(&path); + + match state { + StorageState::Unavailable { .. } => { + // Expected + } + _ => panic!("Expected Unavailable state"), + } + } + + #[test] + fn test_enter_offline_mode_with_cache() { + let temp_dir = TempDir::new().unwrap(); + let state = OfflineModeHandler::enter_offline_mode(temp_dir.path(), true); + + match state { + StorageState::ReadOnly { .. } => { + // Expected + } + _ => panic!("Expected ReadOnly state"), + } + } + + #[test] + fn test_enter_offline_mode_without_cache() { + let temp_dir = TempDir::new().unwrap(); + let state = OfflineModeHandler::enter_offline_mode(temp_dir.path(), false); + + match state { + StorageState::Unavailable { .. } => { + // Expected + } + _ => panic!("Expected Unavailable state"), + } + } + + #[test] + fn test_retry_storage_access_available() { + let temp_dir = TempDir::new().unwrap(); + let result = OfflineModeHandler::retry_storage_access(temp_dir.path()); + + assert!(result); + } + + #[test] + fn test_retry_storage_access_unavailable() { + let path = std::path::PathBuf::from("/nonexistent/path"); + let result = OfflineModeHandler::retry_storage_access(&path); + + assert!(!result); + } + + #[test] + fn test_validate_offline_mode_with_cache() { + let result = OfflineModeHandler::validate_offline_mode(true); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_offline_mode_without_cache() { + let result = OfflineModeHandler::validate_offline_mode(false); + assert!(result.is_err()); + } + + #[test] + fn test_is_external_storage() { + // This test is platform-specific + #[cfg(target_os = "windows")] + { + // UNC paths are external + let unc_path = std::path::PathBuf::from("\\\\server\\share"); + assert!(OfflineModeHandler::is_external_storage(&unc_path)); + } + + #[cfg(target_os = "macos")] + { + // /Volumes paths are external + let volume_path = std::path::PathBuf::from("/Volumes/ExternalDrive"); + assert!(OfflineModeHandler::is_external_storage(&volume_path)); + } + + #[cfg(target_os = "linux")] + { + // /mnt and /media paths are external + let mnt_path = std::path::PathBuf::from("/mnt/external"); + assert!(OfflineModeHandler::is_external_storage(&mnt_path)); + + let media_path = std::path::PathBuf::from("/media/user/external"); + assert!(OfflineModeHandler::is_external_storage(&media_path)); + } + } +} diff --git a/crates/ricecoder-storage/src/relocation.rs b/crates/ricecoder-storage/src/relocation.rs new file mode 100644 index 00000000..d4745d23 --- /dev/null +++ b/crates/ricecoder-storage/src/relocation.rs @@ -0,0 +1,304 @@ +//! Storage relocation functionality +//! +//! Provides functionality to move global storage to a new location +//! and update configuration pointers. + +use crate::error::{IoOperation, StorageError, StorageResult}; +use std::fs; +use std::path::{Path, PathBuf}; +use tracing::{debug, info}; + +/// Marker file that stores the current global storage path +const STORAGE_PATH_MARKER: &str = ".ricecoder_storage_path"; + +/// Relocation service for moving global storage +pub struct RelocationService; + +impl RelocationService { + /// Relocate storage from one location to another + /// + /// # Arguments + /// + /// * `from` - Current storage location + /// * `to` - New storage location + /// + /// # Errors + /// + /// Returns error if relocation fails + pub fn relocate(from: &Path, to: &Path) -> StorageResult<()> { + debug!("Starting relocation from {} to {}", from.display(), to.display()); + + // Validate source exists + if !from.exists() { + return Err(StorageError::relocation_error( + from.to_path_buf(), + to.to_path_buf(), + "Source directory does not exist", + )); + } + + // Validate target doesn't exist or is empty + if to.exists() { + if to.is_dir() { + let entries = fs::read_dir(to).map_err(|e| { + StorageError::io_error(to.to_path_buf(), IoOperation::Read, e) + })?; + + if entries.count() > 0 { + return Err(StorageError::relocation_error( + from.to_path_buf(), + to.to_path_buf(), + "Target directory is not empty", + )); + } + } else { + return Err(StorageError::relocation_error( + from.to_path_buf(), + to.to_path_buf(), + "Target path exists and is not a directory", + )); + } + } + + // Create target parent directory if needed + if let Some(parent) = to.parent() { + if !parent.exists() { + fs::create_dir_all(parent).map_err(|e| { + StorageError::directory_creation_failed(parent.to_path_buf(), e) + })?; + } + } + + // Copy all data from source to target + Self::copy_dir_recursive(from, to)?; + + // Verify data integrity by checking file count + let source_count = Self::count_files(from)?; + let target_count = Self::count_files(to)?; + + if source_count != target_count { + // Cleanup target on failure + let _ = fs::remove_dir_all(to); + return Err(StorageError::relocation_error( + from.to_path_buf(), + to.to_path_buf(), + format!( + "Data integrity check failed: {} files in source, {} in target", + source_count, target_count + ), + )); + } + + // Update configuration pointer + Self::update_storage_path_marker(to)?; + + info!( + "Successfully relocated storage from {} to {}", + from.display(), + to.display() + ); + + Ok(()) + } + + /// Get the stored storage path from marker file + /// + /// # Arguments + /// + /// * `marker_dir` - Directory containing the marker file + /// + /// # Returns + /// + /// Returns the stored path if marker exists, None otherwise + pub fn get_stored_path(marker_dir: &Path) -> StorageResult> { + let marker_path = marker_dir.join(STORAGE_PATH_MARKER); + + if !marker_path.exists() { + return Ok(None); + } + + let content = fs::read_to_string(&marker_path).map_err(|e| { + StorageError::io_error(marker_path.clone(), IoOperation::Read, e) + })?; + + let path = PathBuf::from(content.trim()); + debug!("Read stored storage path: {}", path.display()); + + Ok(Some(path)) + } + + /// Copy directory recursively + fn copy_dir_recursive(src: &Path, dst: &Path) -> StorageResult<()> { + fs::create_dir_all(dst).map_err(|e| { + StorageError::directory_creation_failed(dst.to_path_buf(), e) + })?; + + for entry in fs::read_dir(src).map_err(|e| { + StorageError::io_error(src.to_path_buf(), IoOperation::Read, e) + })? { + let entry = entry.map_err(|e| { + StorageError::io_error(src.to_path_buf(), IoOperation::Read, e) + })?; + + let path = entry.path(); + let file_name = entry.file_name(); + let dest_path = dst.join(&file_name); + + if path.is_dir() { + Self::copy_dir_recursive(&path, &dest_path)?; + } else { + fs::copy(&path, &dest_path).map_err(|e| { + StorageError::io_error(path.clone(), IoOperation::Read, e) + })?; + } + } + + Ok(()) + } + + /// Count files in directory recursively + fn count_files(dir: &Path) -> StorageResult { + let mut count = 0; + + for entry in fs::read_dir(dir).map_err(|e| { + StorageError::io_error(dir.to_path_buf(), IoOperation::Read, e) + })? { + let entry = entry.map_err(|e| { + StorageError::io_error(dir.to_path_buf(), IoOperation::Read, e) + })?; + + let path = entry.path(); + + if path.is_dir() { + count += Self::count_files(&path)?; + } else { + count += 1; + } + } + + Ok(count) + } + + /// Update the storage path marker file + fn update_storage_path_marker(storage_path: &Path) -> StorageResult<()> { + // Get the home directory to store the marker + let home = dirs::home_dir().ok_or_else(|| { + StorageError::path_resolution_error("Could not determine home directory") + })?; + + let marker_path = home.join(STORAGE_PATH_MARKER); + + let path_str = storage_path + .to_str() + .ok_or_else(|| { + StorageError::path_resolution_error("Could not convert path to string") + })?; + + fs::write(&marker_path, path_str).map_err(|e| { + StorageError::io_error(marker_path.clone(), IoOperation::Write, e) + })?; + + debug!("Updated storage path marker: {}", marker_path.display()); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_relocation_success() -> StorageResult<()> { + let source_dir = TempDir::new().unwrap(); + let target_dir = TempDir::new().unwrap(); + + // Create some test files in source + fs::write(source_dir.path().join("file1.txt"), "content1").unwrap(); + fs::write(source_dir.path().join("file2.txt"), "content2").unwrap(); + + let source_path = source_dir.path().to_path_buf(); + let target_path = target_dir.path().join("new_storage"); + + // Perform relocation + RelocationService::relocate(&source_path, &target_path)?; + + // Verify files were copied + assert!(target_path.join("file1.txt").exists()); + assert!(target_path.join("file2.txt").exists()); + + let content1 = fs::read_to_string(target_path.join("file1.txt")).unwrap(); + assert_eq!(content1, "content1"); + + Ok(()) + } + + #[test] + fn test_relocation_with_subdirs() -> StorageResult<()> { + let source_dir = TempDir::new().unwrap(); + let target_dir = TempDir::new().unwrap(); + + // Create nested structure + fs::create_dir(source_dir.path().join("subdir")).unwrap(); + fs::write(source_dir.path().join("file1.txt"), "content1").unwrap(); + fs::write(source_dir.path().join("subdir/file2.txt"), "content2").unwrap(); + + let source_path = source_dir.path().to_path_buf(); + let target_path = target_dir.path().join("new_storage"); + + // Perform relocation + RelocationService::relocate(&source_path, &target_path)?; + + // Verify structure was copied + assert!(target_path.join("file1.txt").exists()); + assert!(target_path.join("subdir/file2.txt").exists()); + + Ok(()) + } + + #[test] + fn test_relocation_source_not_exists() { + let source_path = PathBuf::from("/nonexistent/source"); + let target_path = PathBuf::from("/nonexistent/target"); + + let result = RelocationService::relocate(&source_path, &target_path); + assert!(result.is_err()); + } + + #[test] + fn test_relocation_target_not_empty() -> StorageResult<()> { + let source_dir = TempDir::new().unwrap(); + let target_dir = TempDir::new().unwrap(); + + // Create file in source + fs::write(source_dir.path().join("file1.txt"), "content1").unwrap(); + + // Create file in target to make it non-empty + fs::write(target_dir.path().join("existing.txt"), "existing").unwrap(); + + let source_path = source_dir.path().to_path_buf(); + let target_path = target_dir.path().to_path_buf(); + + let result = RelocationService::relocate(&source_path, &target_path); + assert!(result.is_err()); + + Ok(()) + } + + #[test] + fn test_count_files() -> StorageResult<()> { + let temp_dir = TempDir::new().unwrap(); + + // Create test structure + fs::write(temp_dir.path().join("file1.txt"), "content1").unwrap(); + fs::write(temp_dir.path().join("file2.txt"), "content2").unwrap(); + fs::create_dir(temp_dir.path().join("subdir")).unwrap(); + fs::write(temp_dir.path().join("subdir/file3.txt"), "content3").unwrap(); + + let count = RelocationService::count_files(temp_dir.path())?; + assert_eq!(count, 3); + + Ok(()) + } +} diff --git a/crates/ricecoder-storage/tests/property_config_format_roundtrip.rs b/crates/ricecoder-storage/tests/property_config_format_roundtrip.rs new file mode 100644 index 00000000..6d55ee4c --- /dev/null +++ b/crates/ricecoder-storage/tests/property_config_format_roundtrip.rs @@ -0,0 +1,111 @@ +//! Property-based tests for configuration format round-trip +//! +//! **Feature: ricecoder-storage, Property 3: Configuration Format Round-Trip** +//! **Validates: Requirements 4.1, 4.2, 4.3** + +use proptest::prelude::*; +use ricecoder_storage::config::{Config, ConfigLoader, DefaultsConfig, ProvidersConfig}; +use ricecoder_storage::types::ConfigFormat; +use std::collections::HashMap; + +/// Strategy for generating valid configurations +fn config_strategy() -> impl Strategy { + ( + prop::option::of("[a-z_]+"), + prop::option::of("[a-z_]+"), + prop::option::of(0.0f32..1.0f32), + prop::option::of(1u32..4096u32), + ) + .prop_map(|(provider, model, temp, tokens)| { + let mut providers = ProvidersConfig { + api_keys: HashMap::new(), + endpoints: HashMap::new(), + default_provider: provider.clone(), + }; + + // Add some API keys + if let Some(p) = provider { + providers.api_keys.insert(p, "test-key".to_string()); + } + + Config { + providers, + defaults: DefaultsConfig { + model, + temperature: temp, + max_tokens: tokens, + }, + steering: Vec::new(), + custom: HashMap::new(), + } + }) +} + +proptest! { + /// Property: YAML round-trip preserves configuration + /// + /// For any valid configuration, serializing to YAML and deserializing + /// should produce an equivalent configuration. + #[test] + fn prop_yaml_roundtrip(config in config_strategy()) { + let yaml = ConfigLoader::serialize(&config, ConfigFormat::Yaml) + .expect("Failed to serialize to YAML"); + let deserialized = ConfigLoader::load_from_string(&yaml, ConfigFormat::Yaml, "test.yaml") + .expect("Failed to deserialize from YAML"); + + prop_assert_eq!(config, deserialized); + } + + /// Property: TOML round-trip preserves configuration + /// + /// For any valid configuration, serializing to TOML and deserializing + /// should produce an equivalent configuration. + #[test] + fn prop_toml_roundtrip(config in config_strategy()) { + let toml = ConfigLoader::serialize(&config, ConfigFormat::Toml) + .expect("Failed to serialize to TOML"); + let deserialized = ConfigLoader::load_from_string(&toml, ConfigFormat::Toml, "test.toml") + .expect("Failed to deserialize from TOML"); + + prop_assert_eq!(config, deserialized); + } + + /// Property: JSON round-trip preserves configuration + /// + /// For any valid configuration, serializing to JSON and deserializing + /// should produce an equivalent configuration. + #[test] + fn prop_json_roundtrip(config in config_strategy()) { + let json = ConfigLoader::serialize(&config, ConfigFormat::Json) + .expect("Failed to serialize to JSON"); + let deserialized = ConfigLoader::load_from_string(&json, ConfigFormat::Json, "test.json") + .expect("Failed to deserialize from JSON"); + + prop_assert_eq!(config, deserialized); + } + + /// Property: All formats produce equivalent configurations + /// + /// For any valid configuration, serializing to different formats and + /// deserializing should produce equivalent configurations. + #[test] + fn prop_format_equivalence(config in config_strategy()) { + let yaml = ConfigLoader::serialize(&config, ConfigFormat::Yaml) + .expect("Failed to serialize to YAML"); + let toml = ConfigLoader::serialize(&config, ConfigFormat::Toml) + .expect("Failed to serialize to TOML"); + let json = ConfigLoader::serialize(&config, ConfigFormat::Json) + .expect("Failed to serialize to JSON"); + + let from_yaml = ConfigLoader::load_from_string(&yaml, ConfigFormat::Yaml, "test.yaml") + .expect("Failed to deserialize from YAML"); + let from_toml = ConfigLoader::load_from_string(&toml, ConfigFormat::Toml, "test.toml") + .expect("Failed to deserialize from TOML"); + let from_json = ConfigLoader::load_from_string(&json, ConfigFormat::Json, "test.json") + .expect("Failed to deserialize from JSON"); + + prop_assert_eq!(from_yaml.clone(), from_toml.clone()); + prop_assert_eq!(from_toml.clone(), from_json.clone()); + prop_assert_eq!(from_yaml, from_json); + } +} diff --git a/crates/ricecoder-storage/tests/property_config_merge.proptest-regressions b/crates/ricecoder-storage/tests/property_config_merge.proptest-regressions new file mode 100644 index 00000000..bd8f06a3 --- /dev/null +++ b/crates/ricecoder-storage/tests/property_config_merge.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 3aa1498122cfc258f304523a5f52e12233f7c585a6d4dac98976ab12185ce0ee # shrinks to global_key = "_", global_value = "0", project_key = "_", project_value = "-" diff --git a/crates/ricecoder-storage/tests/property_config_merge.rs b/crates/ricecoder-storage/tests/property_config_merge.rs new file mode 100644 index 00000000..7d09ae9d --- /dev/null +++ b/crates/ricecoder-storage/tests/property_config_merge.rs @@ -0,0 +1,208 @@ +//! Property-based tests for configuration merging +//! +//! **Feature: ricecoder-storage, Property 5: Configuration Merge Precedence** +//! **Feature: ricecoder-storage, Property 13: Merge Decision Logging** +//! **Validates: Requirements 2.7, 3.3, 3.4** + +use proptest::prelude::*; +use ricecoder_storage::config::{Config, ConfigMerger, DefaultsConfig, ProvidersConfig}; +use std::collections::HashMap; + +/// Strategy for generating valid configurations +fn config_strategy() -> impl Strategy { + ( + prop::option::of("[a-z_]+"), + prop::option::of("[a-z_]+"), + prop::option::of(0.0f32..1.0f32), + ) + .prop_map(|(provider, model, temp)| { + let mut providers = ProvidersConfig { + api_keys: HashMap::new(), + endpoints: HashMap::new(), + default_provider: provider.clone(), + }; + + if let Some(p) = provider { + providers.api_keys.insert(p, "test-key".to_string()); + } + + Config { + providers, + defaults: DefaultsConfig { + model, + temperature: temp, + max_tokens: None, + }, + steering: Vec::new(), + custom: HashMap::new(), + } + }) +} + +proptest! { + /// Property: Project config overrides global config + /// + /// For any global and project configurations, merging should result in + /// project values overriding global values. + #[test] + fn prop_project_overrides_global( + global in config_strategy(), + project in config_strategy(), + ) { + let defaults = Config::default(); + let (merged, _) = ConfigMerger::merge(defaults, Some(global.clone()), Some(project.clone()), None); + + // Project values should override global values + if let Some(ref project_provider) = project.providers.default_provider { + assert_eq!(merged.providers.default_provider, Some(project_provider.clone())); + } + if let Some(ref project_model) = project.defaults.model { + assert_eq!(merged.defaults.model, Some(project_model.clone())); + } + } + + /// Property: Environment config overrides all + /// + /// For any combination of configs, environment config should override all others. + #[test] + fn prop_env_overrides_all( + global in config_strategy(), + project in config_strategy(), + env in config_strategy(), + ) { + let defaults = Config::default(); + let (merged, _) = ConfigMerger::merge(defaults, Some(global), Some(project), Some(env.clone())); + + // Environment values should override all + if let Some(ref env_provider) = env.providers.default_provider { + assert_eq!(merged.providers.default_provider, Some(env_provider.clone())); + } + if let Some(ref env_model) = env.defaults.model { + assert_eq!(merged.defaults.model, Some(env_model.clone())); + } + } + + /// Property: Merge decisions are logged + /// + /// For any merge operation, merge decisions should be recorded for all + /// values that were overridden. + #[test] + fn prop_merge_decisions_logged( + global in config_strategy(), + project in config_strategy(), + ) { + let defaults = Config::default(); + let (_, decisions) = ConfigMerger::merge(defaults, Some(global.clone()), Some(project.clone()), None); + + // If project overrides global, there should be a decision + if let Some(ref project_provider) = project.providers.default_provider { + if project_provider != &global.providers.default_provider.clone().unwrap_or_default() { + let has_decision = decisions.iter().any(|d| { + d.key == "providers.default_provider" && d.source == "project" + }); + // Only assert if global had a different value + if global.providers.default_provider.is_some() && + global.providers.default_provider != project.providers.default_provider { + assert!(has_decision); + } + } + } + } + + /// Property: Global config is applied when no project config + /// + /// For any global configuration without project config, the global values + /// should be used. + #[test] + fn prop_global_applied_without_project(global in config_strategy()) { + let defaults = Config::default(); + let (merged, _) = ConfigMerger::merge(defaults, Some(global.clone()), None, None); + + // Global values should be applied + assert_eq!(merged.providers.default_provider, global.providers.default_provider); + assert_eq!(merged.defaults.model, global.defaults.model); + } + + /// Property: Defaults are used when no other config + /// + /// For any default configuration without global or project config, + /// the default values should be used. + #[test] + fn prop_defaults_used_without_other_config(defaults in config_strategy()) { + let (merged, _) = ConfigMerger::merge(defaults.clone(), None, None, None); + + // Defaults should be used + assert_eq!(merged.providers.default_provider, defaults.providers.default_provider); + assert_eq!(merged.defaults.model, defaults.defaults.model); + } + + /// Property: API keys are merged, not replaced + /// + /// For any global and project configurations with different API keys, + /// merging should combine them. If they have the same key, project overrides. + #[test] + fn prop_api_keys_merged( + global_key in "[a-z_]+", + global_value in "[a-zA-Z0-9_\\-]+", + project_key in "[a-z_]+", + project_value in "[a-zA-Z0-9_\\-]+", + ) { + let mut global = Config::default(); + global.providers.api_keys.insert(global_key.clone(), global_value.clone()); + + let mut project = Config::default(); + project.providers.api_keys.insert(project_key.clone(), project_value.clone()); + + let defaults = Config::default(); + let (merged, _) = ConfigMerger::merge(defaults, Some(global), Some(project), None); + + // If keys are different, both should be present + if global_key != project_key { + assert_eq!(merged.providers.api_keys.get(&global_key), Some(&global_value)); + assert_eq!(merged.providers.api_keys.get(&project_key), Some(&project_value)); + } else { + // If keys are the same, project should override + assert_eq!(merged.providers.api_keys.get(&global_key), Some(&project_value)); + } + } + + /// Property: Merge decisions include source information + /// + /// For any merge operation, all decisions should include the source + /// (global, project, or environment). + #[test] + fn prop_merge_decisions_have_source( + global in config_strategy(), + project in config_strategy(), + ) { + let defaults = Config::default(); + let (_, decisions) = ConfigMerger::merge(defaults, Some(global), Some(project), None); + + // All decisions should have a source + for decision in decisions { + assert!(!decision.source.is_empty()); + assert!( + decision.source == "global" || + decision.source == "project" || + decision.source == "environment" + ); + } + } + + /// Property: Merge decisions include key information + /// + /// For any merge operation, all decisions should include the configuration key. + #[test] + fn prop_merge_decisions_have_key( + global in config_strategy(), + project in config_strategy(), + ) { + let defaults = Config::default(); + let (_, decisions) = ConfigMerger::merge(defaults, Some(global), Some(project), None); + + // All decisions should have a key + for decision in decisions { + assert!(!decision.key.is_empty()); + } + } +} diff --git a/crates/ricecoder-storage/tests/property_document_format_support.rs b/crates/ricecoder-storage/tests/property_document_format_support.rs new file mode 100644 index 00000000..4b792d19 --- /dev/null +++ b/crates/ricecoder-storage/tests/property_document_format_support.rs @@ -0,0 +1,164 @@ +//! Property-based tests for document format support +//! +//! **Feature: ricecoder-storage, Property 4: Document Format Support** +//! **Validates: Requirements 2.3, 2.4** + +use proptest::prelude::*; +use ricecoder_storage::config::{DocumentLoader, Document}; +use ricecoder_storage::types::DocumentFormat; +use tempfile::TempDir; + +/// Strategy for generating valid document content +fn document_content_strategy() -> impl Strategy { + r"[a-zA-Z0-9\n\r\t !@#$%^&*()_+=\-\[\]{};:',.<>?/\\|`~]*" + .prop_map(|s| s.to_string()) +} + +proptest! { + /// Property: YAML documents can be stored and retrieved + /// + /// For any valid document content, storing a YAML document and retrieving it + /// should produce an equivalent document. + #[test] + fn prop_yaml_document_roundtrip(content in document_content_strategy()) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let file_path = temp_dir.path().join("document.yaml"); + + let original = Document { + content: content.clone(), + format: DocumentFormat::Yaml, + }; + + DocumentLoader::save_to_file(&original, &file_path) + .expect("Failed to save document"); + + let loaded = DocumentLoader::load_from_file(&file_path) + .expect("Failed to load document"); + + prop_assert_eq!(original, loaded); + } + + /// Property: Markdown documents can be stored and retrieved + /// + /// For any valid document content, storing a Markdown document and retrieving it + /// should produce an equivalent document. + #[test] + fn prop_markdown_document_roundtrip(content in document_content_strategy()) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let file_path = temp_dir.path().join("document.md"); + + let original = Document { + content: content.clone(), + format: DocumentFormat::Markdown, + }; + + DocumentLoader::save_to_file(&original, &file_path) + .expect("Failed to save document"); + + let loaded = DocumentLoader::load_from_file(&file_path) + .expect("Failed to load document"); + + prop_assert_eq!(original, loaded); + } + + /// Property: Format detection works for YAML files + /// + /// For any YAML file, the format should be correctly detected as YAML. + #[test] + fn prop_yaml_format_detection(content in document_content_strategy()) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let file_path = temp_dir.path().join("document.yaml"); + + let doc = Document { + content: content.clone(), + format: DocumentFormat::Yaml, + }; + + DocumentLoader::save_to_file(&doc, &file_path) + .expect("Failed to save document"); + + let detected_format = DocumentLoader::detect_format(&file_path) + .expect("Failed to detect format"); + + prop_assert_eq!(detected_format, DocumentFormat::Yaml); + } + + /// Property: Format detection works for Markdown files + /// + /// For any Markdown file, the format should be correctly detected as Markdown. + #[test] + fn prop_markdown_format_detection(content in document_content_strategy()) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let file_path = temp_dir.path().join("document.md"); + + let doc = Document { + content: content.clone(), + format: DocumentFormat::Markdown, + }; + + DocumentLoader::save_to_file(&doc, &file_path) + .expect("Failed to save document"); + + let detected_format = DocumentLoader::detect_format(&file_path) + .expect("Failed to detect format"); + + prop_assert_eq!(detected_format, DocumentFormat::Markdown); + } + + /// Property: Document content is preserved exactly + /// + /// For any document, the content should be preserved exactly without modification. + #[test] + fn prop_document_content_preservation(content in document_content_strategy()) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let file_path = temp_dir.path().join("document.yaml"); + + let original = Document { + content: content.clone(), + format: DocumentFormat::Yaml, + }; + + DocumentLoader::save_to_file(&original, &file_path) + .expect("Failed to save document"); + + let loaded = DocumentLoader::load_from_file(&file_path) + .expect("Failed to load document"); + + prop_assert_eq!(loaded.content, content); + } + + /// Property: Both YAML and Markdown formats preserve content + /// + /// For any document content, both YAML and Markdown formats should preserve + /// the content exactly. + #[test] + fn prop_both_formats_preserve_content(content in document_content_strategy()) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + let yaml_path = temp_dir.path().join("document.yaml"); + let md_path = temp_dir.path().join("document.md"); + + let yaml_doc = Document { + content: content.clone(), + format: DocumentFormat::Yaml, + }; + + let md_doc = Document { + content: content.clone(), + format: DocumentFormat::Markdown, + }; + + DocumentLoader::save_to_file(&yaml_doc, &yaml_path) + .expect("Failed to save YAML document"); + DocumentLoader::save_to_file(&md_doc, &md_path) + .expect("Failed to save Markdown document"); + + let loaded_yaml = DocumentLoader::load_from_file(&yaml_path) + .expect("Failed to load YAML document"); + let loaded_md = DocumentLoader::load_from_file(&md_path) + .expect("Failed to load Markdown document"); + + prop_assert_eq!(loaded_yaml.content, content.clone()); + prop_assert_eq!(loaded_md.content, content); + } +} diff --git a/crates/ricecoder-storage/tests/property_env_override.rs b/crates/ricecoder-storage/tests/property_env_override.rs new file mode 100644 index 00000000..fd0e021b --- /dev/null +++ b/crates/ricecoder-storage/tests/property_env_override.rs @@ -0,0 +1,157 @@ +//! Property-based tests for environment variable override +//! +//! **Feature: ricecoder-storage, Property 8: Environment Variable Override** +//! **Validates: Requirements 4.4** + +use proptest::prelude::*; +use ricecoder_storage::config::{Config, EnvOverrides}; +use std::collections::HashMap; + +/// Strategy for generating valid environment variable overrides +fn env_override_strategy() -> impl Strategy> { + prop::collection::hash_map( + r"[a-z_]+", + r"[a-zA-Z0-9_\-\.]+", + 0..5, + ) +} + +proptest! { + /// Property: Environment variables override configuration values + /// + /// For any environment variable override, the configuration value should be + /// overridden by the environment variable value. + #[test] + fn prop_env_overrides_config(overrides in env_override_strategy()) { + let mut config = Config::default(); + config.defaults.model = Some("gpt-4".to_string()); + config.providers.default_provider = Some("openai".to_string()); + + EnvOverrides::apply_overrides(&mut config, &overrides); + + // Verify that overrides were applied + for (key, value) in &overrides { + if key == "defaults.model" { + assert_eq!(config.defaults.model, Some(value.clone())); + } + if key == "providers.default_provider" { + assert_eq!(config.providers.default_provider, Some(value.clone())); + } + } + } + + /// Property: Provider default override works + /// + /// For any provider name, setting RICECODER_PROVIDERS_DEFAULT_PROVIDER should + /// override the default provider. + #[test] + fn prop_provider_default_override(provider_name in "[a-z_]+") { + let mut config = Config::default(); + let mut overrides = HashMap::new(); + overrides.insert("providers.default_provider".to_string(), provider_name.clone()); + + EnvOverrides::apply_overrides(&mut config, &overrides); + + assert_eq!(config.providers.default_provider, Some(provider_name)); + } + + /// Property: Model default override works + /// + /// For any model name, setting RICECODER_DEFAULTS_MODEL should override the model. + #[test] + fn prop_model_default_override(model_name in "[a-z_]+") { + let mut config = Config::default(); + let mut overrides = HashMap::new(); + overrides.insert("defaults.model".to_string(), model_name.clone()); + + EnvOverrides::apply_overrides(&mut config, &overrides); + + assert_eq!(config.defaults.model, Some(model_name)); + } + + /// Property: Temperature override works + /// + /// For any valid temperature value, setting RICECODER_DEFAULTS_TEMPERATURE should + /// override the temperature. + #[test] + fn prop_temperature_override(temp in 0.0f32..1.0f32) { + let mut config = Config::default(); + let mut overrides = HashMap::new(); + overrides.insert("defaults.temperature".to_string(), temp.to_string()); + + EnvOverrides::apply_overrides(&mut config, &overrides); + + assert_eq!(config.defaults.temperature, Some(temp)); + } + + /// Property: Max tokens override works + /// + /// For any valid max tokens value, setting RICECODER_DEFAULTS_MAX_TOKENS should + /// override the max tokens. + #[test] + fn prop_max_tokens_override(tokens in 1u32..4096u32) { + let mut config = Config::default(); + let mut overrides = HashMap::new(); + overrides.insert("defaults.max_tokens".to_string(), tokens.to_string()); + + EnvOverrides::apply_overrides(&mut config, &overrides); + + assert_eq!(config.defaults.max_tokens, Some(tokens)); + } + + /// Property: API key override works + /// + /// For any provider and API key, setting RICECODER_PROVIDERS_API_KEYS_ + /// should override the API key. + #[test] + fn prop_api_key_override(provider in "[a-z_]+", api_key in "[a-zA-Z0-9_\\-]+") { + let mut config = Config::default(); + let mut overrides = HashMap::new(); + let key = format!("providers.api_keys.{}", provider); + overrides.insert(key, api_key.clone()); + + EnvOverrides::apply_overrides(&mut config, &overrides); + + assert_eq!(config.providers.api_keys.get(&provider), Some(&api_key)); + } + + /// Property: Multiple overrides work together + /// + /// For any combination of overrides, all should be applied correctly. + #[test] + fn prop_multiple_overrides_work( + provider in "[a-z_]+", + model in "[a-z_]+", + temp in 0.0f32..1.0f32, + ) { + let mut config = Config::default(); + let mut overrides = HashMap::new(); + overrides.insert("providers.default_provider".to_string(), provider.clone()); + overrides.insert("defaults.model".to_string(), model.clone()); + overrides.insert("defaults.temperature".to_string(), temp.to_string()); + + EnvOverrides::apply_overrides(&mut config, &overrides); + + assert_eq!(config.providers.default_provider, Some(provider)); + assert_eq!(config.defaults.model, Some(model)); + assert_eq!(config.defaults.temperature, Some(temp)); + } + + /// Property: Unknown overrides are stored in custom map + /// + /// For any unknown configuration path, the override should be stored in the + /// custom map. + #[test] + fn prop_unknown_overrides_stored(path in "[a-z_]+\\.[a-z_]+", value in "[a-zA-Z0-9_\\-]+") { + let mut config = Config::default(); + let mut overrides = HashMap::new(); + overrides.insert(path.clone(), value.clone()); + + EnvOverrides::apply_overrides(&mut config, &overrides); + + // Unknown paths should be stored in custom map + if !path.starts_with("providers") && !path.starts_with("defaults") { + assert!(config.custom.contains_key(&path)); + } + } +} diff --git a/crates/ricecoder-storage/tests/property_industry_file_merge_precedence.proptest-regressions b/crates/ricecoder-storage/tests/property_industry_file_merge_precedence.proptest-regressions new file mode 100644 index 00000000..162c415c --- /dev/null +++ b/crates/ricecoder-storage/tests/property_industry_file_merge_precedence.proptest-regressions @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc f05d5fd693a892d3c7b3693f55533a2744509944ff67a97db20c0ace0f6b1779 # shrinks to combination = IndustryFilesCombination { has_cursorrules: false, has_cursor_dir: false, has_claude_md: false, has_agents_md: false, has_windsurfrules: false, has_clinerules: false, has_aider_config: false, has_copilot_instructions: true, has_continue_dir: false, has_kiro_dir: false } +cc 5afa86a425db503bf06780e7fa54c11c666ce82157d513afe5d3886342f825e3 # shrinks to combination = IndustryFilesCombination { has_cursorrules: false, has_cursor_dir: false, has_claude_md: false, has_agents_md: false, has_windsurfrules: false, has_clinerules: false, has_aider_config: false, has_copilot_instructions: false, has_continue_dir: false, has_kiro_dir: false } diff --git a/crates/ricecoder-storage/tests/property_industry_file_merge_precedence.rs b/crates/ricecoder-storage/tests/property_industry_file_merge_precedence.rs new file mode 100644 index 00000000..1ebf156d --- /dev/null +++ b/crates/ricecoder-storage/tests/property_industry_file_merge_precedence.rs @@ -0,0 +1,320 @@ +//! Property-based test for industry file merge precedence +//! +//! **Feature: ricecoder-storage, Property 9: Industry File Merge Precedence** +//! **Validates: Requirements 5.10** +//! +//! Property: For any combination of industry-standard configuration files +//! (`.cursorrules`, `CLAUDE.md`, etc.), merging should follow the defined +//! precedence order (environment > project > legacy > global > defaults). + +use proptest::prelude::*; +use ricecoder_storage::industry::{ + AiderAdapter, AgentsAdapter, ClaudeAdapter, ClineAdapter, ContinueDevAdapter, CopilotAdapter, + CursorAdapter, IndustryFileAdapter, IndustryFileDetector, KiroAdapter, WindsurfAdapter, +}; +use std::fs; +use tempfile::TempDir; + +/// Strategy for generating combinations of industry files +#[derive(Debug, Clone)] +struct IndustryFilesCombination { + has_cursorrules: bool, + has_cursor_dir: bool, + has_claude_md: bool, + has_agents_md: bool, + has_windsurfrules: bool, + has_clinerules: bool, + has_aider_config: bool, + has_copilot_instructions: bool, + has_continue_dir: bool, + has_kiro_dir: bool, +} + +fn industry_files_strategy() -> impl Strategy { + ( + any::(), + any::(), + any::(), + any::(), + any::(), + any::(), + any::(), + any::(), + any::(), + any::(), + ) + .prop_map( + |( + has_cursorrules, + has_cursor_dir, + has_claude_md, + has_agents_md, + has_windsurfrules, + has_clinerules, + has_aider_config, + has_copilot_instructions, + has_continue_dir, + has_kiro_dir, + )| { + IndustryFilesCombination { + has_cursorrules, + has_cursor_dir, + has_claude_md, + has_agents_md, + has_windsurfrules, + has_clinerules, + has_aider_config, + has_copilot_instructions, + has_continue_dir, + has_kiro_dir, + } + }, + ) +} + +fn create_industry_files( + temp_dir: &TempDir, + combination: &IndustryFilesCombination, +) -> std::io::Result<()> { + let root = temp_dir.path(); + + if combination.has_cursorrules { + fs::write(root.join(".cursorrules"), "# Cursor rules")?; + } + + if combination.has_cursor_dir { + fs::create_dir(root.join(".cursor"))?; + fs::write(root.join(".cursor/settings.json"), r#"{"key": "value"}"#)?; + } + + if combination.has_claude_md { + fs::write(root.join("CLAUDE.md"), "# Claude instructions")?; + } + + if combination.has_agents_md { + fs::write(root.join("AGENTS.md"), "# Agent instructions")?; + } + + if combination.has_windsurfrules { + fs::write(root.join(".windsurfrules"), "# Windsurf rules")?; + } + + if combination.has_clinerules { + fs::write(root.join(".clinerules"), "# Cline rules")?; + } + + if combination.has_aider_config { + fs::write(root.join(".aider.conf.yml"), "model: gpt-4")?; + } + + if combination.has_copilot_instructions { + fs::create_dir_all(root.join(".github"))?; + fs::write( + root.join(".github/copilot-instructions.md"), + "# Copilot instructions", + )?; + } + + if combination.has_continue_dir { + fs::create_dir(root.join(".continue"))?; + fs::write(root.join(".continue/config.json"), r#"{"models": ["gpt-4"]}"#)?; + } + + if combination.has_kiro_dir { + fs::create_dir(root.join(".kiro"))?; + fs::create_dir(root.join(".kiro/specs"))?; + fs::write(root.join(".kiro/specs/spec.md"), "# Spec")?; + } + + Ok(()) +} + +proptest! { + #[test] + fn prop_industry_file_merge_precedence(combination in industry_files_strategy()) { + let temp_dir = TempDir::new().unwrap(); + create_industry_files(&temp_dir, &combination).unwrap(); + + // Create detector with all adapters + let adapters: Vec> = vec![ + Box::new(KiroAdapter::new()), // Priority: 100 + Box::new(CursorAdapter::new()), // Priority: 50 + Box::new(ClaudeAdapter::new()), // Priority: 50 + Box::new(AgentsAdapter::new()), // Priority: 40 + Box::new(WindsurfAdapter::new()), // Priority: 50 + Box::new(ClineAdapter::new()), // Priority: 50 + Box::new(AiderAdapter::new()), // Priority: 50 + Box::new(CopilotAdapter::new()), // Priority: 50 + Box::new(ContinueDevAdapter::new()), // Priority: 50 + ]; + + let detector = IndustryFileDetector::new(adapters); + + // Get detected files + let detected = detector.detect_files(temp_dir.path()); + + // Verify that Kiro has highest priority if it exists + if combination.has_kiro_dir { + prop_assert!(!detected.is_empty()); + prop_assert_eq!(&detected[0].adapter_name, "kiro"); + } + + // Verify that agents has lower priority than other tools + if combination.has_agents_md && !combination.has_kiro_dir { + // If agents is the only one, it should be first + if detected.len() == 1 { + prop_assert_eq!(&detected[0].adapter_name, "agents"); + } else { + // If there are other tools, agents should not be first + // (unless it's the only one with priority 40) + let has_higher_priority = detected.iter().any(|d| { + d.adapter_name != "agents" + && (d.adapter_name == "kiro" + || d.adapter_name == "cursor" + || d.adapter_name == "claude" + || d.adapter_name == "windsurf" + || d.adapter_name == "cline" + || d.adapter_name == "aider" + || d.adapter_name == "copilot" + || d.adapter_name == "continue") + }); + + if has_higher_priority { + prop_assert_ne!(&detected[0].adapter_name, "agents"); + } + } + } + + // Verify that all detected adapters can actually handle the directory + for detection in &detected { + let adapter_name = &detection.adapter_name; + let can_handle = match adapter_name.as_str() { + "kiro" => combination.has_kiro_dir, + "cursor" => combination.has_cursorrules || combination.has_cursor_dir, + "claude" => combination.has_claude_md, + "agents" => combination.has_agents_md, + "windsurf" => combination.has_windsurfrules, + "cline" => combination.has_clinerules, + "aider" => combination.has_aider_config, + "copilot" => combination.has_copilot_instructions, + "continue" => combination.has_continue_dir, + _ => false, + }; + + prop_assert!( + can_handle, + "Adapter {} detected but files don't exist", + adapter_name + ); + } + + // Verify that if no files exist, no adapters are detected + if !combination.has_cursorrules + && !combination.has_cursor_dir + && !combination.has_claude_md + && !combination.has_agents_md + && !combination.has_windsurfrules + && !combination.has_clinerules + && !combination.has_aider_config + && !combination.has_copilot_instructions + && !combination.has_continue_dir + && !combination.has_kiro_dir + { + prop_assert!(detected.is_empty()); + } + + // Verify that detected adapters are sorted by priority (highest first) + if detected.len() > 1 { + for i in 0..detected.len() - 1 { + prop_assert!( + detected[i].priority >= detected[i + 1].priority, + "Adapters not sorted by priority: {} ({}) should be >= {} ({})", + detected[i].adapter_name, + detected[i].priority, + detected[i + 1].adapter_name, + detected[i + 1].priority + ); + } + } + } + + #[test] + fn prop_best_adapter_respects_precedence(combination in industry_files_strategy()) { + let temp_dir = TempDir::new().unwrap(); + create_industry_files(&temp_dir, &combination).unwrap(); + + let adapters: Vec> = vec![ + Box::new(KiroAdapter::new()), + Box::new(CursorAdapter::new()), + Box::new(ClaudeAdapter::new()), + Box::new(AgentsAdapter::new()), + Box::new(WindsurfAdapter::new()), + Box::new(ClineAdapter::new()), + Box::new(AiderAdapter::new()), + Box::new(CopilotAdapter::new()), + Box::new(ContinueDevAdapter::new()), + ]; + + let detector = IndustryFileDetector::new(adapters); + let best = detector.get_best_adapter(temp_dir.path()); + + // If any files exist, best adapter should be Some + if combination.has_cursorrules + || combination.has_cursor_dir + || combination.has_claude_md + || combination.has_agents_md + || combination.has_windsurfrules + || combination.has_clinerules + || combination.has_aider_config + || combination.has_copilot_instructions + || combination.has_continue_dir + || combination.has_kiro_dir + { + // If best is Some, verify it's the highest priority adapter + if let Some(best_adapter) = best { + // If Kiro exists, it should be the best (highest priority) + if combination.has_kiro_dir { + prop_assert_eq!(best_adapter.name(), "kiro"); + } + + // Verify the best adapter can actually handle the directory + prop_assert!(best_adapter.can_handle(temp_dir.path())); + } + } else { + // If no files exist, best adapter should be None + prop_assert!(best.is_none()); + } + } + + #[test] + fn prop_adapter_config_reading_is_consistent(combination in industry_files_strategy()) { + let temp_dir = TempDir::new().unwrap(); + create_industry_files(&temp_dir, &combination).unwrap(); + + // Test each adapter individually + let cursor_adapter = CursorAdapter::new(); + if cursor_adapter.can_handle(temp_dir.path()) { + let config = cursor_adapter.read_config(temp_dir.path()).unwrap(); + // Should have at least one steering rule if files exist + if combination.has_cursorrules || combination.has_cursor_dir { + prop_assert!(!config.steering.is_empty()); + } + } + + let claude_adapter = ClaudeAdapter::new(); + if claude_adapter.can_handle(temp_dir.path()) { + let config = claude_adapter.read_config(temp_dir.path()).unwrap(); + if combination.has_claude_md { + prop_assert!(!config.steering.is_empty()); + } + } + + let kiro_adapter = KiroAdapter::new(); + if kiro_adapter.can_handle(temp_dir.path()) { + let config = kiro_adapter.read_config(temp_dir.path()).unwrap(); + if combination.has_kiro_dir { + prop_assert!(!config.steering.is_empty()); + } + } + } +} diff --git a/crates/ricecoder-storage/tests/property_offline_mode.rs b/crates/ricecoder-storage/tests/property_offline_mode.rs new file mode 100644 index 00000000..ae8daccb --- /dev/null +++ b/crates/ricecoder-storage/tests/property_offline_mode.rs @@ -0,0 +1,202 @@ +//! Property-based tests for offline mode +//! **Feature: ricecoder-storage, Property 15: Offline Mode Graceful Degradation** +//! **Validates: Requirements 6.6** + +use proptest::prelude::*; +use ricecoder_storage::{OfflineModeHandler, StorageState}; +use std::fs; +use tempfile::TempDir; + +/// Strategy for generating cache availability states +fn cache_availability_strategy() -> impl Strategy { + prop_oneof![Just(true), Just(false)] +} + +/// Property 15: Offline Mode Graceful Degradation +/// For any unavailable storage directory, the OfflineModeHandler should operate +/// in read-only mode using cached data and produce a warning log entry. +#[test] +fn prop_offline_mode_graceful_degradation() { + proptest!(|(cache_available in cache_availability_strategy())| { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create a storage state that simulates unavailable storage + let state = OfflineModeHandler::enter_offline_mode(temp_dir.path(), cache_available); + + // Verify the state is correct based on cache availability + match state { + StorageState::ReadOnly { cached_at } => { + // Should only be ReadOnly if cache is available + assert!( + cache_available, + "ReadOnly state should only occur when cache is available" + ); + // Verify cached_at is a valid timestamp + assert!( + !cached_at.is_empty(), + "cached_at should contain a timestamp" + ); + } + StorageState::Unavailable { reason } => { + // Should be Unavailable if cache is not available + assert!( + !cache_available, + "Unavailable state should occur when cache is not available" + ); + assert!( + !reason.is_empty(), + "Unavailable state should have a reason" + ); + } + StorageState::Available => { + panic!("Should not be Available in offline mode"); + } + } + }); +} + +/// Property: Offline mode validation respects cache availability +/// The validation should succeed only when cache is available +#[test] +fn prop_offline_mode_validation() { + proptest!(|(cache_available in cache_availability_strategy())| { + let result = OfflineModeHandler::validate_offline_mode(cache_available); + + if cache_available { + assert!( + result.is_ok(), + "Validation should succeed when cache is available" + ); + } else { + assert!( + result.is_err(), + "Validation should fail when cache is not available" + ); + } + }); +} + +/// Property: Storage availability check is consistent +/// Checking the same storage path multiple times should return the same result +#[test] +fn prop_storage_availability_consistency() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Check availability multiple times + let state1 = OfflineModeHandler::check_storage_availability(temp_dir.path()); + let state2 = OfflineModeHandler::check_storage_availability(temp_dir.path()); + let state3 = OfflineModeHandler::check_storage_availability(temp_dir.path()); + + // All checks should return the same state + assert_eq!( + state1, state2, + "Storage availability should be consistent across checks" + ); + assert_eq!( + state2, state3, + "Storage availability should be consistent across checks" + ); +} + +/// Property: Retry storage access returns correct result +/// After checking storage availability, retry should return the same result +#[test] +fn prop_retry_storage_access_consistency() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Check initial availability + let initial_available = OfflineModeHandler::retry_storage_access(temp_dir.path()); + + // Retry should return the same result + let retry_available = OfflineModeHandler::retry_storage_access(temp_dir.path()); + + assert_eq!( + initial_available, retry_available, + "Retry should return consistent result" + ); +} + +/// Property: Offline mode handles missing storage gracefully +/// When storage path doesn't exist, offline mode should handle it gracefully +#[test] +fn prop_offline_mode_missing_storage() { + proptest!(|(cache_available in cache_availability_strategy())| { + let nonexistent_path = std::path::PathBuf::from("/nonexistent/storage/path"); + + // Check availability of missing storage + let state = OfflineModeHandler::check_storage_availability(&nonexistent_path); + + // Should be unavailable + match state { + StorageState::Unavailable { reason } => { + assert!( + !reason.is_empty(), + "Unavailable state should have a reason" + ); + } + _ => { + panic!("Missing storage should result in Unavailable state"); + } + } + + // Enter offline mode with missing storage + let offline_state = OfflineModeHandler::enter_offline_mode(&nonexistent_path, cache_available); + + // Verify offline state is correct + match offline_state { + StorageState::ReadOnly { .. } => { + assert!( + cache_available, + "ReadOnly state should only occur when cache is available" + ); + } + StorageState::Unavailable { .. } => { + assert!( + !cache_available, + "Unavailable state should occur when cache is not available" + ); + } + StorageState::Available => { + panic!("Should not be Available for missing storage"); + } + } + }); +} + +/// Property: Offline mode logging doesn't fail +/// Logging offline warnings should not cause errors +#[test] +fn prop_offline_mode_logging() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // This should not panic or error + OfflineModeHandler::log_offline_warning( + temp_dir.path(), + "Test offline warning", + ); + + // If we get here, logging succeeded + assert!(true); +} + +/// Property: External storage detection is consistent +/// Checking if storage is external multiple times should return the same result +#[test] +fn prop_external_storage_detection_consistency() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Check if external multiple times + let is_external1 = OfflineModeHandler::is_external_storage(temp_dir.path()); + let is_external2 = OfflineModeHandler::is_external_storage(temp_dir.path()); + let is_external3 = OfflineModeHandler::is_external_storage(temp_dir.path()); + + // All checks should return the same result + assert_eq!( + is_external1, is_external2, + "External storage detection should be consistent" + ); + assert_eq!( + is_external2, is_external3, + "External storage detection should be consistent" + ); +} diff --git a/crates/ricecoder-storage/tests/property_relocation_roundtrip.proptest-regressions b/crates/ricecoder-storage/tests/property_relocation_roundtrip.proptest-regressions new file mode 100644 index 00000000..1b772e4f --- /dev/null +++ b/crates/ricecoder-storage/tests/property_relocation_roundtrip.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 2f0a8b7ab5e60f4182d11b6dfb6ae200d4133ad8cdce8462589c86461e9dfacf # shrinks to files = [("_.txt", " "), ("_.txt", "\t")] diff --git a/crates/ricecoder-storage/tests/property_relocation_roundtrip.rs b/crates/ricecoder-storage/tests/property_relocation_roundtrip.rs new file mode 100644 index 00000000..094d14f1 --- /dev/null +++ b/crates/ricecoder-storage/tests/property_relocation_roundtrip.rs @@ -0,0 +1,221 @@ +//! Property-based tests for storage relocation +//! **Feature: ricecoder-storage, Property 11: Relocation Round-Trip** +//! **Validates: Requirements 6.5** + +use proptest::prelude::*; +use ricecoder_storage::RelocationService; +use std::fs; +use tempfile::TempDir; + +/// Strategy for generating valid file names +fn file_name_strategy() -> impl Strategy { + r"[a-zA-Z0-9_\-]{1,10}" + .prop_map(|s| format!("{}.txt", s)) + .prop_filter("Name should be non-empty", |s| !s.is_empty()) +} + +/// Strategy for generating valid file content +fn file_content_strategy() -> impl Strategy { + r"[a-zA-Z0-9 \n\t]*" + .prop_filter("Content should be valid", |s| !s.is_empty()) +} + +/// Strategy for generating file structures with unique names +fn file_structure_strategy() -> impl Strategy> { + prop::collection::vec((file_name_strategy(), file_content_strategy()), 1..10) + .prop_map(|mut files| { + // Ensure unique file names by adding index + for (i, (name, _)) in files.iter_mut().enumerate() { + let base = name.trim_end_matches(".txt"); + *name = format!("{}_{}.txt", base, i); + } + files + }) +} + +/// Property 11: Relocation Round-Trip +/// For any storage with data, relocating to a new path should move all data +/// to the new location, and all data should be accessible from the new location +/// with identical content. +#[test] +fn prop_relocation_roundtrip() { + proptest!(|(files in file_structure_strategy())| { + let source_dir = TempDir::new().expect("Failed to create source temp dir"); + let target_parent = TempDir::new().expect("Failed to create target parent temp dir"); + + // Create source files + for (name, content) in &files { + fs::write(source_dir.path().join(name), content) + .expect("Failed to write source file"); + } + + let source_path = source_dir.path().to_path_buf(); + let target_path = target_parent.path().join("relocated_storage"); + + // Perform relocation + RelocationService::relocate(&source_path, &target_path) + .expect("Relocation should succeed"); + + // Verify all files exist in target with identical content + for (name, content) in &files { + let target_file = target_path.join(name); + assert!( + target_file.exists(), + "File {} should exist in target location", + name + ); + + let retrieved_content = fs::read_to_string(&target_file) + .expect("Failed to read target file"); + + assert_eq!( + &retrieved_content, content, + "Content of {} should be identical after relocation", + name + ); + } + }); +} + +/// Property: Relocation preserves directory structure +/// Relocating storage with nested directories should preserve the structure +#[test] +fn prop_relocation_preserves_structure() { + proptest!(|(files in file_structure_strategy())| { + let source_dir = TempDir::new().expect("Failed to create source temp dir"); + let target_parent = TempDir::new().expect("Failed to create target parent temp dir"); + + // Create nested structure + fs::create_dir(source_dir.path().join("subdir1")).expect("Failed to create subdir1"); + fs::create_dir(source_dir.path().join("subdir2")).expect("Failed to create subdir2"); + + // Create files in root and subdirectories + for (i, (name, content)) in files.iter().enumerate() { + let subdir = if i % 2 == 0 { "subdir1" } else { "subdir2" }; + let file_path = source_dir.path().join(subdir).join(name); + fs::write(&file_path, content) + .expect("Failed to write file in subdir"); + } + + let source_path = source_dir.path().to_path_buf(); + let target_path = target_parent.path().join("relocated_storage"); + + // Perform relocation + RelocationService::relocate(&source_path, &target_path) + .expect("Relocation should succeed"); + + // Verify directory structure is preserved + assert!( + target_path.join("subdir1").exists(), + "subdir1 should exist in target" + ); + assert!( + target_path.join("subdir2").exists(), + "subdir2 should exist in target" + ); + + // Verify files are in correct subdirectories + for (i, (name, content)) in files.iter().enumerate() { + let subdir = if i % 2 == 0 { "subdir1" } else { "subdir2" }; + let target_file = target_path.join(subdir).join(name); + + assert!( + target_file.exists(), + "File {} should exist in {} after relocation", + name, + subdir + ); + + let retrieved_content = fs::read_to_string(&target_file) + .expect("Failed to read target file"); + + assert_eq!( + &retrieved_content, content, + "Content should be preserved in subdirectory" + ); + } + }); +} + +/// Property: Relocation is idempotent for file count +/// The number of files in source and target should be identical after relocation +#[test] +fn prop_relocation_file_count_preserved() { + proptest!(|(files in file_structure_strategy())| { + let source_dir = TempDir::new().expect("Failed to create source temp dir"); + let target_parent = TempDir::new().expect("Failed to create target parent temp dir"); + + // Create source files + for (name, content) in &files { + fs::write(source_dir.path().join(name), content) + .expect("Failed to write source file"); + } + + let source_path = source_dir.path().to_path_buf(); + let target_path = target_parent.path().join("relocated_storage"); + + // Count files before relocation + let source_count = files.len(); + + // Perform relocation + RelocationService::relocate(&source_path, &target_path) + .expect("Relocation should succeed"); + + // Count files in target + let target_count = fs::read_dir(&target_path) + .expect("Failed to read target directory") + .count(); + + assert_eq!( + source_count, target_count, + "File count should be preserved after relocation" + ); + }); +} + +/// Property: Relocation fails gracefully with non-empty target +/// Attempting to relocate to a non-empty target should fail +#[test] +fn prop_relocation_fails_with_nonempty_target() { + proptest!(|(files in file_structure_strategy())| { + let source_dir = TempDir::new().expect("Failed to create source temp dir"); + let target_dir = TempDir::new().expect("Failed to create target temp dir"); + + // Create source files + for (name, content) in &files { + fs::write(source_dir.path().join(name), content) + .expect("Failed to write source file"); + } + + // Create a file in target to make it non-empty + fs::write(target_dir.path().join("existing.txt"), "existing content") + .expect("Failed to create existing file in target"); + + let source_path = source_dir.path().to_path_buf(); + let target_path = target_dir.path().to_path_buf(); + + // Attempt relocation should fail + let result = RelocationService::relocate(&source_path, &target_path); + assert!( + result.is_err(), + "Relocation to non-empty target should fail" + ); + }); +} + +/// Property: Relocation fails gracefully with non-existent source +/// Attempting to relocate from a non-existent source should fail +#[test] +fn prop_relocation_fails_with_nonexistent_source() { + let target_parent = TempDir::new().expect("Failed to create target parent temp dir"); + + let source_path = target_parent.path().join("nonexistent_source"); + let target_path = target_parent.path().join("target"); + + // Attempt relocation should fail + let result = RelocationService::relocate(&source_path, &target_path); + assert!( + result.is_err(), + "Relocation from non-existent source should fail" + ); +} diff --git a/crates/ricecoder-storage/tests/property_storage_modes.rs b/crates/ricecoder-storage/tests/property_storage_modes.rs new file mode 100644 index 00000000..5c14bead --- /dev/null +++ b/crates/ricecoder-storage/tests/property_storage_modes.rs @@ -0,0 +1,192 @@ +//! Property-based tests for storage modes +//! +//! **Feature: ricecoder-storage, Property 6: Project-Isolated Mode Isolation** +//! **Feature: ricecoder-storage, Property 7: Global-Only Mode Isolation** +//! **Validates: Requirements 3.1, 3.2** + +use ricecoder_storage::config::StorageModeHandler; +use ricecoder_storage::types::StorageMode; +use std::fs; +use tempfile::TempDir; + +#[test] +fn test_global_only_loads_global() { + let global_dir = TempDir::new().expect("Failed to create temp dir"); + let project_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create global config + let global_config = r#" +providers: + default_provider: openai + api_keys: {} + endpoints: {} +defaults: {} +"#; + fs::write(global_dir.path().join("config.yaml"), global_config) + .expect("Failed to write global config"); + + // Create project config + let project_config = r#" +providers: + default_provider: anthropic + api_keys: {} + endpoints: {} +defaults: {} +"#; + fs::write(project_dir.path().join("config.yaml"), project_config) + .expect("Failed to write project config"); + + let config = StorageModeHandler::load_for_mode( + StorageMode::GlobalOnly, + Some(global_dir.path()), + Some(project_dir.path()), + ).expect("Failed to load config"); + + // Should have global provider, not project provider + assert_eq!(config.providers.default_provider, Some("openai".to_string())); + assert_ne!(config.providers.default_provider, Some("anthropic".to_string())); +} + +#[test] +fn test_project_only_loads_project() { + let global_dir = TempDir::new().expect("Failed to create temp dir"); + let project_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create global config + let global_config = r#" +providers: + default_provider: openai + api_keys: {} + endpoints: {} +defaults: {} +"#; + fs::write(global_dir.path().join("config.yaml"), global_config) + .expect("Failed to write global config"); + + // Create project config + let project_config = r#" +providers: + default_provider: anthropic + api_keys: {} + endpoints: {} +defaults: {} +"#; + fs::write(project_dir.path().join("config.yaml"), project_config) + .expect("Failed to write project config"); + + let config = StorageModeHandler::load_for_mode( + StorageMode::ProjectOnly, + Some(global_dir.path()), + Some(project_dir.path()), + ).expect("Failed to load config"); + + // Should have project provider, not global provider + assert_eq!(config.providers.default_provider, Some("anthropic".to_string())); + assert_ne!(config.providers.default_provider, Some("openai".to_string())); +} + +#[test] +fn test_merged_mode_combines_configs() { + let global_dir = TempDir::new().expect("Failed to create temp dir"); + let project_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create global config + let global_config = r#" +providers: + default_provider: openai + api_keys: {} + endpoints: {} +defaults: {} +"#; + fs::write(global_dir.path().join("config.yaml"), global_config) + .expect("Failed to write global config"); + + // Create project config + let project_config = r#" +providers: + default_provider: anthropic + api_keys: {} + endpoints: {} +defaults: {} +"#; + fs::write(project_dir.path().join("config.yaml"), project_config) + .expect("Failed to write project config"); + + let config = StorageModeHandler::load_for_mode( + StorageMode::Merged, + Some(global_dir.path()), + Some(project_dir.path()), + ).expect("Failed to load config"); + + // Should have project provider (project overrides global) + assert_eq!(config.providers.default_provider, Some("anthropic".to_string())); +} + +#[test] +fn test_global_only_ignores_missing_project() { + let global_dir = TempDir::new().expect("Failed to create temp dir"); + let project_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create only global config + let global_config = r#" +providers: + default_provider: openai + api_keys: {} + endpoints: {} +defaults: {} +"#; + fs::write(global_dir.path().join("config.yaml"), global_config) + .expect("Failed to write global config"); + + let config = StorageModeHandler::load_for_mode( + StorageMode::GlobalOnly, + Some(global_dir.path()), + Some(project_dir.path()), + ).expect("Failed to load config"); + + // Should have global provider + assert_eq!(config.providers.default_provider, Some("openai".to_string())); +} + +#[test] +fn test_project_only_ignores_missing_global() { + let global_dir = TempDir::new().expect("Failed to create temp dir"); + let project_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create only project config + let project_config = r#" +providers: + default_provider: anthropic + api_keys: {} + endpoints: {} +defaults: {} +"#; + fs::write(project_dir.path().join("config.yaml"), project_config) + .expect("Failed to write project config"); + + let config = StorageModeHandler::load_for_mode( + StorageMode::ProjectOnly, + Some(global_dir.path()), + Some(project_dir.path()), + ).expect("Failed to load config"); + + // Should have project provider + assert_eq!(config.providers.default_provider, Some("anthropic".to_string())); +} + +#[test] +fn test_merged_mode_handles_missing_configs() { + let global_dir = TempDir::new().expect("Failed to create temp dir"); + let project_dir = TempDir::new().expect("Failed to create temp dir"); + + // Don't create any configs + + let config = StorageModeHandler::load_for_mode( + StorageMode::Merged, + Some(global_dir.path()), + Some(project_dir.path()), + ).expect("Failed to load config"); + + // Should return defaults + assert_eq!(config.providers.default_provider, None); +}