From ffe2b7ac95b24b9913507c025fca6fb5bafc955f Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Sat, 17 Jan 2026 22:02:38 +0100 Subject: [PATCH 01/19] feat(core): Add space and filesystem modules This includes the emergence of the abstraction pattern used within the core library. Idea is to always work from a trait which defines the interface, thus allowing a default implemenation to be implemented while still being open to easily add new implemenations and proper unit testing without the hassle of too much mockery. --- Cargo.lock | 1456 +++++++++++++++++++-- crates/flow-cli/Cargo.toml | 1 + crates/flow-cli/src/lib.rs | 5 +- crates/flow-core/Cargo.toml | 3 + crates/flow-core/src/errors.rs | 177 +++ crates/flow-core/src/filesystem.rs | 57 + crates/flow-core/src/filesystem/local.rs | 113 ++ crates/flow-core/src/filesystem/traits.rs | 117 ++ crates/flow-core/src/lib.rs | 111 +- crates/flow-core/src/space.rs | 200 +++ crates/flow-core/src/space/default.rs | 235 ++++ crates/flow-core/src/space/locator.rs | 156 +++ crates/flow-core/src/space/metadata.rs | 80 ++ crates/flow-core/src/space/traits.rs | 119 ++ crates/flow/src/main.rs | 2 +- 15 files changed, 2736 insertions(+), 96 deletions(-) create mode 100644 crates/flow-core/src/errors.rs create mode 100644 crates/flow-core/src/filesystem.rs create mode 100644 crates/flow-core/src/filesystem/local.rs create mode 100644 crates/flow-core/src/filesystem/traits.rs create mode 100644 crates/flow-core/src/space.rs create mode 100644 crates/flow-core/src/space/default.rs create mode 100644 crates/flow-core/src/space/locator.rs create mode 100644 crates/flow-core/src/space/metadata.rs create mode 100644 crates/flow-core/src/space/traits.rs diff --git a/Cargo.lock b/Cargo.lock index 6858635..5bed719 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,28 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.21" @@ -67,6 +89,48 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "append-only-bytes" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac436601d6bdde674a0d7fb593e829ffe7b3387c351b356dd20e2d40f5bf3ee5" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "arref" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ccd462b64c3c72f1be8305905a85d85403d768e8690c9b8bd3b9009a5761679" + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "backtrace" version = "0.3.76" @@ -97,12 +161,52 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "cc" +version = "1.2.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -137,10 +241,10 @@ version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -149,12 +253,174 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.17", +] + [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.111", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "ensure-cov" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33753185802e107b8fa907192af1f0eca13b1fb33327a59266d650fef29b2b4e" + +[[package]] +name = "enum-as-inner" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9720bba047d567ffc8a3cba48bf19126600e249ab7f128e9233e6376976a116" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[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" @@ -165,6 +431,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + [[package]] name = "flow" version = "0.1.0" @@ -175,7 +447,7 @@ dependencies = [ "flow-server", "flow-tui", "miette", - "thiserror", + "thiserror 2.0.17", "tokio", ] @@ -184,7 +456,7 @@ name = "flow-app" version = "0.1.0" dependencies = [ "miette", - "thiserror", + "thiserror 2.0.17", "tokio", ] @@ -193,8 +465,9 @@ name = "flow-cli" version = "0.1.0" dependencies = [ "clap", + "flow-core", "miette", - "thiserror", + "thiserror 2.0.17", "tokio", ] @@ -202,8 +475,11 @@ dependencies = [ name = "flow-core" version = "0.1.0" dependencies = [ + "loro", "miette", - "thiserror", + "serde", + "serde_json", + "thiserror 2.0.17", "tokio", ] @@ -213,7 +489,7 @@ version = "0.1.0" dependencies = [ "clap", "miette", - "thiserror", + "thiserror 2.0.17", "tokio", ] @@ -223,7 +499,7 @@ version = "0.1.0" dependencies = [ "clap", "miette", - "thiserror", + "thiserror 2.0.17", "tokio", ] @@ -233,22 +509,177 @@ version = "0.1.0" dependencies = [ "clap", "miette", - "thiserror", + "thiserror 2.0.17", "tokio", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "generic-btree" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c1bce85c110ab718fd139e0cc89c51b63bd647b14a767e24bdfc77c83df79b" +dependencies = [ + "arref", + "heapless 0.9.2", + "itertools 0.11.0", + "loro-thunderdome", + "proc-macro2", + "rustc-hash", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[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 = "gimli" version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "im" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" +dependencies = [ + "bitmaps", + "rand_core", + "rand_xoshiro", + "serde", + "sized-chunks", + "typenum", + "version_check", +] + [[package]] name = "is_ci" version = "1.2.0" @@ -261,6 +692,52 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + [[package]] name = "libc" version = "0.2.178" @@ -283,7 +760,190 @@ dependencies = [ ] [[package]] -name = "memchr" +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "loro" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75216d8f99725531a30f7b00901ee154a4f8a9b7f125bfe032e197d4c7ffb8c" +dependencies = [ + "enum-as-inner 0.6.1", + "generic-btree", + "loro-common", + "loro-delta", + "loro-internal", + "loro-kv-store", + "rustc-hash", + "tracing", +] + +[[package]] +name = "loro-common" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70363ea05a9c507fd9d58b65dc414bf515f636d69d8ab53e50ecbe8d27eef90c" +dependencies = [ + "arbitrary", + "enum-as-inner 0.6.1", + "leb128", + "loro-rle", + "nonmax", + "rustc-hash", + "serde", + "serde_columnar", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "loro-delta" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eafa788a72c1cbf0b7dc08a862cd7cc31b96d99c2ef749cdc94c2330f9494d3" +dependencies = [ + "arrayvec", + "enum-as-inner 0.5.1", + "generic-btree", + "heapless 0.8.0", +] + +[[package]] +name = "loro-internal" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f447044ec3d3ba572623859add3334bd87b84340ee5fdf00315bfee0e3ad3e3f" +dependencies = [ + "append-only-bytes", + "arref", + "bytes", + "either", + "ensure-cov", + "enum-as-inner 0.6.1", + "enum_dispatch", + "generic-btree", + "getrandom 0.2.17", + "im", + "itertools 0.12.1", + "leb128", + "loom", + "loro-common", + "loro-delta", + "loro-kv-store", + "loro-rle", + "loro_fractional_index", + "md5", + "nonmax", + "num", + "num-traits", + "once_cell", + "parking_lot", + "pest", + "pest_derive", + "postcard", + "pretty_assertions", + "rand", + "rustc-hash", + "serde", + "serde_columnar", + "serde_json", + "smallvec", + "thiserror 1.0.69", + "thread_local", + "tracing", + "wasm-bindgen", + "xxhash-rust", +] + +[[package]] +name = "loro-kv-store" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78beebc933a33c26495c9a98f05b38bc0a4c0a337ecfbd3146ce1f9437eec71f" +dependencies = [ + "bytes", + "ensure-cov", + "loro-common", + "lz4_flex", + "once_cell", + "quick_cache", + "rustc-hash", + "tracing", + "xxhash-rust", +] + +[[package]] +name = "loro-rle" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76400c3eea6bb39b013406acce964a8db39311534e308286c8d8721baba8ee20" +dependencies = [ + "append-only-bytes", + "num", + "smallvec", +] + +[[package]] +name = "loro-thunderdome" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3d053a135388e6b1df14e8af1212af5064746e9b87a06a345a7a779ee9695a" + +[[package]] +name = "loro_fractional_index" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427c8ea186958094052b971fe7e322a934b034c3bf62f0458ccea04fcd687ba1" +dependencies = [ + "once_cell", + "rand", + "serde", +] + +[[package]] +name = "lz4_flex" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" @@ -308,137 +968,507 @@ dependencies = [ ] [[package]] -name = "miette-derive" -version = "7.6.0" +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + +[[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 = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless 0.7.17", + "serde", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[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 = "quick_cache" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ada44a88ef953a3294f6eb55d2007ba44646015e18613d2f213016379203ef3" +dependencies = [ + "ahash", + "equivalent", + "hashbrown", + "parking_lot", +] + +[[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.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +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 = "regex-automata" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ - "proc-macro2", - "quote", - "syn", + "aho-corasick", + "memchr", + "regex-syntax", ] [[package]] -name = "miniz_oxide" -version = "0.8.9" +name = "regex-syntax" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "adler2", + "semver", ] [[package]] -name = "mio" -version = "1.1.1" +name = "rustix" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ + "bitflags", + "errno", "libc", - "wasi", + "linux-raw-sys", "windows-sys 0.61.2", ] [[package]] -name = "object" -version = "0.37.3" +name = "rustversion" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "scoped-tls" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" [[package]] -name = "owo-colors" -version = "4.2.3" +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "parking_lot" -version = "0.12.5" +name = "semver" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ - "lock_api", - "parking_lot_core", + "serde_core", + "serde_derive", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "serde_columnar" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "2a16e404f17b16d0273460350e29b02d76ba0d70f34afdc9a4fa034c97d6c6eb" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", + "itertools 0.11.0", + "postcard", + "serde", + "serde_columnar_derive", + "thiserror 1.0.69", ] [[package]] -name = "pin-project-lite" -version = "0.2.16" +name = "serde_columnar_derive" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "45958fce4903f67e871fbf15ac78e289269b21ebd357d6fecacdba233629112e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.111", +] [[package]] -name = "proc-macro2" -version = "1.0.103" +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ - "unicode-ident", + "serde_derive", ] [[package]] -name = "quote" -version = "1.0.42" +name = "serde_derive" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", + "quote", + "syn 2.0.111", ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "serde_json" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "bitflags", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", ] [[package]] -name = "rustc-demangle" -version = "0.1.27" +name = "sha2" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] [[package]] -name = "rustix" -version = "1.1.3" +name = "sharded-slab" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", + "lazy_static", ] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "shlex" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" @@ -450,11 +1480,24 @@ dependencies = [ "libc", ] +[[package]] +name = "sized-chunks" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -466,6 +1509,21 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -493,6 +1551,17 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.111" @@ -524,13 +1593,33 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", ] [[package]] @@ -541,7 +1630,16 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", ] [[package]] @@ -569,9 +1667,88 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -602,18 +1779,93 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[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.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.111", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -696,3 +1948,47 @@ name = "windows_x86_64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "zmij" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" diff --git a/crates/flow-cli/Cargo.toml b/crates/flow-cli/Cargo.toml index 5ab8648..91f6633 100644 --- a/crates/flow-cli/Cargo.toml +++ b/crates/flow-cli/Cargo.toml @@ -13,6 +13,7 @@ categories.workspace = true rust-version.workspace = true [dependencies] +flow-core = { workspace = true } clap = { workspace = true } tokio = { workspace = true } miette = { workspace = true } diff --git a/crates/flow-cli/src/lib.rs b/crates/flow-cli/src/lib.rs index 8f37ba0..6fdbe5e 100644 --- a/crates/flow-cli/src/lib.rs +++ b/crates/flow-cli/src/lib.rs @@ -1,4 +1,5 @@ use clap::Subcommand; +use flow_core::Space; use miette::Result; #[derive(Subcommand)] @@ -11,7 +12,9 @@ pub enum Commands { /// # Errors /// /// Returns an error if the command execution fails. -pub fn run(cmd: &Commands) -> Result<()> { +pub async fn run(cmd: &Commands) -> Result<()> { + let _space = Space::load("test".to_owned()).await?; + match cmd { Commands::Test => println!("This is a test"), } diff --git a/crates/flow-core/Cargo.toml b/crates/flow-core/Cargo.toml index 3c4b000..957a8a3 100644 --- a/crates/flow-core/Cargo.toml +++ b/crates/flow-core/Cargo.toml @@ -16,6 +16,9 @@ rust-version.workspace = true tokio = { workspace = true } miette = { workspace = true } thiserror = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +loro = "1.10.3" [lints] workspace = true diff --git a/crates/flow-core/src/errors.rs b/crates/flow-core/src/errors.rs new file mode 100644 index 0000000..82fe2b8 --- /dev/null +++ b/crates/flow-core/src/errors.rs @@ -0,0 +1,177 @@ +//! Error types for flow-core operations. +//! +//! This module defines the error types used throughout the crate. +//! Errors use [`miette`] for rich diagnostic output with error codes, +//! help text, and source code context. +//! +//! # Overview +//! +//! The [`Error`] enum represents all possible errors that can occur when +//! working with Flow spaces and filesystems. Each variant includes: +//! +//! - A human-readable error message +//! - A unique error code (e.g., `flow::io_error`) +//! - Contextual help text to guide users toward a solution +//! +//! # Error Handling +//! +//! All errors in this crate are compatible with [`miette`]'s diagnostic +//! system, which provides rich error reporting in CLI applications. +//! +//! # Examples +//! +//! ``` +//! use flow_core::Error; +//! use std::path::PathBuf; +//! +//! // Errors can be created directly +//! let error = Error::PathNotFound(PathBuf::from("/nonexistent/path")); +//! +//! // They implement Display for human-readable messages +//! println!("Error: {}", error); +//! ``` + +use std::path::PathBuf; + +use miette::Diagnostic; +use thiserror::Error; + +/// Errors that can occur when working with spaces. +/// +/// This enum covers all error conditions that may arise during space +/// initialization, loading, and filesystem operations. Each variant +/// provides detailed diagnostic information through [`miette`]. +/// +/// # Variants +/// +/// | Variant | Error Code | Description | +/// |---------|------------|-------------| +/// | [`Io`](Error::Io) | `flow::io_error` | Low-level filesystem errors | +/// | [`PathNotFound`](Error::PathNotFound) | `flow::path_not_found` | Path does not exist | +/// | [`NotADirectory`](Error::NotADirectory) | `flow::not_a_directory` | Path is not a directory | +/// | [`AlreadyExists`](Error::AlreadyExists) | `flow::already_exists` | Space already exists | +/// | [`DirectoryNotEmpty`](Error::DirectoryNotEmpty) | `flow::directory_not_empty` | Directory has contents | +/// +/// # Examples +/// +/// Matching on specific error variants: +/// +/// ``` +/// use flow_core::Error; +/// use std::path::PathBuf; +/// +/// fn handle_error(error: Error) { +/// match error { +/// Error::PathNotFound(path) => { +/// eprintln!("Path not found: {}", path.display()); +/// } +/// Error::AlreadyExists(path) => { +/// eprintln!("Space already exists at: {}", path.display()); +/// } +/// _ => eprintln!("An error occurred: {}", error), +/// } +/// } +/// ``` +#[derive(Debug, Error, Diagnostic)] +pub enum Error { + /// A filesystem operation failed. + /// + /// This variant wraps low-level I/O errors from the operating system, + /// such as permission denied, disk full, or network errors when + /// accessing remote filesystems. + /// + /// # Error Code + /// + /// `flow::io_error` + /// + /// # Examples + /// + /// ``` + /// use flow_core::Error; + /// use std::io; + /// + /// let io_error = io::Error::new(io::ErrorKind::PermissionDenied, "access denied"); + /// let error: Error = io_error.into(); + /// ``` + #[error("Filesystem error: {0}")] + #[diagnostic(code(flow::io_error))] + Io(#[from] std::io::Error), + + /// The specified path does not exist. + /// + /// This error occurs when attempting to initialize or load a space + /// from a path that does not exist on the filesystem. + /// + /// # Error Code + /// + /// `flow::path_not_found` + /// + /// # Fields + /// + /// * `0` - The path that was not found. + #[error("Path does not exist: {0}")] + #[diagnostic( + code(flow::path_not_found), + url(docsrs), + help("Make sure the directory exists before initializing a space") + )] + PathNotFound(PathBuf), + + /// The specified path is not a directory. + /// + /// This error occurs when a path exists but is a file rather than + /// a directory. Spaces can only be initialized in directories. + /// + /// # Error Code + /// + /// `flow::not_a_directory` + /// + /// # Fields + /// + /// * `0` - The path that is not a directory. + #[error("Path is not a directory: {0}")] + #[diagnostic(code(flow::not_a_directory), url(docsrs), help("Make sure the path is a directory"))] + NotADirectory(PathBuf), + + /// A space already exists at the specified path. + /// + /// This error occurs when attempting to initialize a new space in + /// a directory that already contains a `.flow` directory, indicating + /// an existing space. + /// + /// # Error Code + /// + /// `flow::already_exists` + /// + /// # Fields + /// + /// * `0` - The path where the space already exists. + #[error("A space already exists at: {0}")] + #[diagnostic( + code(flow::already_exists), + url(docsrs), + help("Use `flow open` to open the existing space, or choose a different path") + )] + AlreadyExists(PathBuf), + + /// The directory is not empty. + /// + /// This error occurs when attempting to initialize a space in a + /// directory that contains files or subdirectories. Spaces should + /// be initialized in empty directories to avoid conflicts. + /// + /// # Error Code + /// + /// `flow::directory_not_empty` + /// + /// # Fields + /// + /// * `0` - The path to the non-empty directory. + #[error("Directory is not empty: {0}")] + #[diagnostic( + code(flow::directory_not_empty), + url(docsrs), + help("Initialize a space in an empty directory, or use a different path") + )] + DirectoryNotEmpty(PathBuf), +} diff --git a/crates/flow-core/src/filesystem.rs b/crates/flow-core/src/filesystem.rs new file mode 100644 index 0000000..f14c953 --- /dev/null +++ b/crates/flow-core/src/filesystem.rs @@ -0,0 +1,57 @@ +//! Filesystem abstraction layer for Flow. +//! +//! This module provides a trait-based abstraction over filesystem operations, +//! allowing for different implementations such as local filesystem access, +//! in-memory filesystems for testing, or remote storage backends. +//! +//! # Design +//! +//! The [`Filesystem`] trait defines the core operations needed by Flow: +//! reading and writing files. By abstracting these operations behind a trait, +//! we gain several benefits: +//! +//! - **Testability**: Tests can use an in-memory implementation to avoid +//! touching the real filesystem. +//! - **Flexibility**: Future implementations could support cloud storage, +//! encrypted filesystems, or other backends. +//! - **Isolation**: The core logic doesn't depend on `std::fs` directly. +//! +//! # Implementations +//! +//! This module provides the following implementations of the [`Filesystem`] trait: +//! +//! - [`LocalFilesystem`] - The production implementation that delegates to +//! Tokio's async filesystem APIs for local file I/O. +//! +//! # Re-exports +//! +//! The following types are re-exported for convenience: +//! +//! - [`Filesystem`] - The core trait defining filesystem operations. +//! - [`LocalFilesystem`] - The local filesystem implementation. +//! +//! # Examples +//! +//! ```ignore +//! use std::path::Path; +//! use flow_core::filesystem::{Filesystem, LocalFilesystem}; +//! +//! # async fn example() -> miette::Result<()> { +//! let fs = LocalFilesystem; +//! +//! // Write a file +//! fs.write(Path::new("notes/todo.md"), "# TODO\n- Buy milk").await?; +//! +//! // Read it back +//! let content = fs.read(Path::new("notes/todo.md")).await?; +//! let text = String::from_utf8(content).expect("valid UTF-8"); +//! assert!(text.contains("Buy milk")); +//! # Ok(()) +//! # } +//! ``` + +mod local; +mod traits; + +pub use self::local::LocalFilesystem; +pub use self::traits::Filesystem; diff --git a/crates/flow-core/src/filesystem/local.rs b/crates/flow-core/src/filesystem/local.rs new file mode 100644 index 0000000..aa4d60b --- /dev/null +++ b/crates/flow-core/src/filesystem/local.rs @@ -0,0 +1,113 @@ +//! Local filesystem implementation. +//! +//! This module provides [`LocalFilesystem`], the production implementation +//! of the [`Filesystem`](super::Filesystem) trait that delegates to +//! Tokio's async filesystem APIs. + +use std::path::Path; + +use miette::Result; +use tokio::fs::{create_dir, metadata, read, read_dir, try_exists, write}; + +use crate::errors::Error; +use crate::filesystem::traits::Filesystem; + +/// A [`Filesystem`] implementation that operates on the local filesystem. +/// +/// This is the standard implementation used in production. It delegates +/// to the operating system's filesystem APIs via Tokio's async filesystem +/// operations. +/// +/// # Examples +/// +/// ```ignore +/// use std::path::Path; +/// use flow_core::filesystem::{Filesystem, LocalFilesystem}; +/// +/// # async fn example() -> miette::Result<()> { +/// let fs = LocalFilesystem; +/// +/// // Check if a file exists +/// if fs.exists(Path::new("notes/todo.md")).await? { +/// let content = fs.read(Path::new("notes/todo.md")).await?; +/// println!("File has {} bytes", content.len()); +/// } +/// +/// // Write a new file +/// fs.write(Path::new("notes/new.md"), b"# New Note").await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, Default, Clone, Copy)] +pub struct LocalFilesystem; + +impl Filesystem for LocalFilesystem { + /// Checks if a path exists using [`tokio::fs::try_exists`]. + /// + /// This is a non-blocking operation that queries the filesystem + /// asynchronously. + async fn exists(&self, path: impl AsRef + Send + Sync) -> Result { + try_exists(&path).await.map_err(|e| Error::Io(e).into()) + } + + /// Checks if a path is a directory using [`tokio::fs::metadata`]. + /// + /// Returns an error if the path does not exist, unlike [`exists`](Self::exists) + /// which returns `false` for non-existent paths. + async fn is_dir(&self, path: impl AsRef + Send + Sync) -> Result { + let metadata = metadata(&path).await.map_err(Error::Io)?; + + Ok(metadata.is_dir()) + } + + /// Checks if a directory is empty by attempting to read its first entry. + /// + /// Uses [`tokio::fs::read_dir`] internally. Returns `false` if the path + /// is not a directory (does not error). + async fn is_dir_empty(&self, path: impl AsRef + Send + Sync) -> Result { + if !self.is_dir(&path).await? { + return Ok(false); + } + + let mut entries = read_dir(&path).await.map_err(Error::Io)?; + let is_empty = entries.next_entry().await.map_err(Error::Io)?.is_none(); + + Ok(is_empty) + } + + /// Creates a directory using [`tokio::fs::create_dir`]. + /// + /// This does **not** create parent directories. Use this only when + /// the parent directory is known to exist. + async fn create_dir(&self, path: impl AsRef + Send + Sync) -> Result<()> { + create_dir(&path).await.map_err(|e| Error::Io(e).into()) + } + + /// Writes content to a file using [`tokio::fs::write`]. + /// + /// This operation is atomic on most platforms — the file is either + /// fully written or not modified at all. + async fn write( + &self, + path: impl AsRef + Send + Sync, + contents: impl AsRef<[u8]> + Send + Sync, + ) -> Result<()> { + write(&path, &contents) + .await + .map_err(|e| Error::Io(e).into()) + } + + /// Reads the entire file contents using [`tokio::fs::read`]. + /// + /// The entire file is read into memory. For large files, consider + /// using streaming APIs instead. + async fn read(&self, path: impl AsRef) -> Result> { + let path = path.as_ref(); + read(path).await.map_err(|e| Error::Io(e).into()) + } +} + +#[cfg(test)] +mod tests { + // TODO: Add tests with a mock filesystem implementation +} diff --git a/crates/flow-core/src/filesystem/traits.rs b/crates/flow-core/src/filesystem/traits.rs new file mode 100644 index 0000000..315d05b --- /dev/null +++ b/crates/flow-core/src/filesystem/traits.rs @@ -0,0 +1,117 @@ +use miette::Result; +use std::path::Path; + +/// An abstraction over filesystem operations. +/// +/// This trait defines the minimal set of operations needed for Flow to +/// interact with a filesystem. Implementations must be thread-safe +/// (`Send + Sync`) to support async operations across threads. +/// +/// # Implementors +/// +/// - [`LocalFilesystem`] - Reads and writes to the local filesystem. +/// +/// # Examples +/// +/// Using the filesystem trait with dependency injection: +/// +/// ```ignore +/// struct NoteStore { +/// fs: F, +/// root: PathBuf, +/// } +/// +/// impl NoteStore { +/// async fn save_note(&self, name: &str, content: &str) -> Result<()> { +/// let path = self.root.join(name).with_extension("md"); +/// self.fs.write(&path, content).await +/// } +/// } +/// ``` +pub trait Filesystem: Send + Sync { + /// Checks if a path exists on the filesystem. + /// + /// Returns `true` if the path exists (file or directory), `false` otherwise. + /// + /// # Arguments + /// + /// * `path` - The path to check for existence. + /// + /// # Errors + /// + /// Returns an error if the filesystem cannot be queried (e.g., permission denied). + async fn exists(&self, path: impl AsRef + Send + Sync) -> Result; + + /// Checks if a path is a directory. + /// + /// Returns `true` if the path exists and is a directory, `false` otherwise. + /// + /// # Arguments + /// + /// * `path` - The path to check. + /// + /// # Errors + /// + /// Returns an error if the path does not exist or cannot be queried. + async fn is_dir(&self, path: impl AsRef + Send + Sync) -> Result; + + /// Checks if a directory is empty. + /// + /// Returns `true` if the path is a directory with no entries, `false` otherwise. + /// Returns `false` if the path is not a directory. + /// + /// # Arguments + /// + /// * `path` - The directory path to check. + /// + /// # Errors + /// + /// Returns an error if the directory cannot be read (e.g., permission denied). + async fn is_dir_empty(&self, path: impl AsRef + Send + Sync) -> Result; + + /// Creates a new directory at the given path. + /// + /// The parent directory must already exist. This does not create parent directories. + /// + /// # Arguments + /// + /// * `path` - The path where the directory should be created. + /// + /// # Errors + /// + /// Returns an error if: + /// - The parent directory does not exist. + /// - A file or directory already exists at the path. + /// - Permission is denied. + async fn create_dir(&self, path: impl AsRef + Send + Sync) -> Result<()>; + + /// Writes content to a file at the given path. + /// + /// Creates the file if it doesn't exist, or overwrites it if it does. + /// + /// # Arguments + /// + /// * `path` - The path to the file to write. + /// * `contents` - The byte content to write to the file. + /// + /// # Errors + /// + /// Returns an error if: + /// - The parent directory does not exist. + /// - Permission is denied. + /// - The disk is full or another I/O error occurs. + async fn write(&self, path: impl AsRef + Send + Sync, contents: impl AsRef<[u8]> + Send + Sync) + -> Result<()>; + + /// Reads the entire contents of a file into a byte vector. + /// + /// # Arguments + /// + /// * `path` - The path to the file to read. + /// + /// # Errors + /// + /// Returns an error if the file does not exist or cannot be read. + #[allow(dead_code)] // TODO: Remove once `Space::load` is implemented + async fn read(&self, path: impl AsRef) -> Result>; +} diff --git a/crates/flow-core/src/lib.rs b/crates/flow-core/src/lib.rs index 44bf58f..c662ecf 100644 --- a/crates/flow-core/src/lib.rs +++ b/crates/flow-core/src/lib.rs @@ -1,16 +1,99 @@ -/// Adds two numbers together. -#[must_use] -pub const fn add(left: u64, right: u64) -> u64 { - left + right -} +//! # flow-core +//! +//! Core abstractions for the Flow notes and outliner system. +//! +//! This crate provides the fundamental building blocks for managing +//! notes, spaces, and file system operations. It is designed to be +//! the foundation that other Flow crates build upon. +//! +//! ## Key Concepts +//! +//! - **[`Space`]** - A workspace containing notes, configuration, and metadata. +//! Spaces are the top-level organizational unit in Flow. +//! +//! - **[`Locator`]** - A flexible way to identify spaces, either by their +//! human-readable name or by an explicit filesystem path. +//! +//! - **`Filesystem`** - An abstraction over file I/O operations, enabling +//! testability and potential future support for different storage backends. +//! +//! - **[`Error`]** - Rich error types with diagnostic information for +//! user-friendly error reporting. +//! +//! ## Examples +//! +//! ### Creating a new space +//! +//! ```no_run +//! use std::path::Path; +//! use flow_core::Space; +//! +//! # async fn example() -> miette::Result<()> { +//! let space = Space::init(Path::new("./my-notes"), "personal").await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ### Loading an existing space +//! +//! ```no_run +//! use flow_core::Space; +//! +//! # async fn example() -> miette::Result<()> { +//! // Load by name (resolved from configuration) +//! let space = Space::load("personal").await?; +//! +//! // Or load by explicit path +//! let space = Space::load(std::path::PathBuf::from("./my-notes")).await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ### Error handling +//! +//! ```no_run +//! use std::path::Path; +//! use flow_core::{Space, Error}; +//! +//! # async fn example() -> miette::Result<()> { +//! match Space::init(Path::new("./my-notes"), "personal").await { +//! Ok(space) => println!("Space created successfully!"), +//! Err(report) => { +//! // Errors are miette::Report, providing rich diagnostics +//! eprintln!("Failed to create space: {:?}", report); +//! } +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Module Structure +//! +//! - `space` - Space management, including initialization and loading. +//! - `filesystem` - Filesystem abstraction layer for I/O operations. +//! - `errors` - Error types with rich diagnostic information. +//! +//! ## Feature Flags +//! +//! This crate does not currently define any feature flags. +//! +//! ## Re-exports +//! +//! For convenience, the most commonly used types are re-exported at the +//! crate root: +//! +//! - [`Space`] - The main space type. +//! - [`Locator`] - For identifying spaces by name or path. +//! - [`Error`] - The error type for this crate. +//! +//! [`Space`]: space::Space +//! [`Locator`]: space::Locator -#[cfg(test)] -mod tests { - use super::*; +mod filesystem; - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +mod errors; +mod space; + +pub use self::errors::Error; +pub use self::space::Locator; +pub use self::space::Space; diff --git a/crates/flow-core/src/space.rs b/crates/flow-core/src/space.rs new file mode 100644 index 0000000..7d75071 --- /dev/null +++ b/crates/flow-core/src/space.rs @@ -0,0 +1,200 @@ +//! Space management for Flow. +//! +//! A [`Space`] is the primary organizational unit in Flow — it represents +//! a workspace containing notes, configuration, and metadata. Think of it +//! as a "vault" or "notebook" that groups related content together. +//! +//! # Overview +//! +//! Spaces can be: +//! - **Initialized** at a new location with [`Space::init`] +//! - **Loaded** from an existing location with [`Space::load`] +//! +//! Each space has a human-readable name and lives at a specific filesystem +//! path. Spaces can be located either by name (resolved from global +//! configuration) or by explicit path using a [`Locator`]. +//! +//! # Examples +//! +//! ## Creating a new space +//! +//! ```no_run +//! use std::path::Path; +//! use flow_core::Space; +//! +//! # async fn example() -> miette::Result<()> { +//! // Initialize a new space called "personal" at the given path +//! let space = Space::init(Path::new("./my-notes"), "personal").await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Loading an existing space +//! +//! ```no_run +//! use flow_core::Space; +//! +//! # async fn example() -> miette::Result<()> { +//! // Load by name (resolved from global configuration) +//! let space = Space::load("personal").await?; +//! +//! // Or load by explicit path +//! let space = Space::load(std::path::PathBuf::from("./my-notes")).await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! # Architecture +//! +//! The public [`Space`] struct is a thin wrapper around an internal +//! implementation (`DefaultSpace`). This design +//! allows us to: +//! +//! - Keep the public API simple and stable +//! - Inject different filesystem implementations for testing +//! - Potentially support different space backends in the future + +use std::path::Path; + +use miette::Result; + +use crate::filesystem::LocalFilesystem; +use crate::space::default::DefaultSpace; +use crate::space::traits::Space as SpaceTrait; + +mod default; +mod locator; +mod metadata; +mod traits; + +pub use self::locator::Locator; +pub use self::metadata::Metadata; + +/// A Flow workspace containing notes, configuration, and metadata. +/// +/// `Space` is the main entry point for interacting with a Flow workspace. +/// It provides methods to initialize new spaces and load existing ones. +/// +/// # Examples +/// +/// ```no_run +/// use std::path::Path; +/// use flow_core::Space; +/// +/// # async fn example() -> miette::Result<()> { +/// // Create a new space +/// let space = Space::init(Path::new("./notes"), "work").await?; +/// +/// // Later, load it by name +/// let space = Space::load("work").await?; +/// # Ok(()) +/// # } +/// ``` +/// +/// # Thread Safety +/// +/// `Space` is `Send` and `Sync`, making it safe to share across threads +/// and use in async contexts. +pub struct Space { + /// The underlying space implementation. + #[allow(dead_code)] + inner: DefaultSpace, +} + +impl Space { + /// Initialize a new space at the given path. + /// + /// This creates the necessary directory structure and configuration + /// files for a new Flow space. The space will be registered with the + /// given name, allowing it to be loaded by name in the future. + /// + /// # Arguments + /// + /// * `path` - The directory path where the space will be created. + /// The directory will be created if it doesn't exist. + /// * `name` - A human-readable name for the space. This name can be + /// used later with [`Space::load`] to open the space. + /// + /// # Errors + /// + /// Returns an error if: + /// - A space already exists at the given path + /// - A space with the given name is already registered + /// - The directory cannot be created (e.g., permission denied) + /// - The configuration file cannot be written + /// + /// # Examples + /// + /// ```no_run + /// use std::path::Path; + /// use flow_core::Space; + /// + /// # async fn example() -> miette::Result<()> { + /// let space = Space::init(Path::new("./my-notes"), "personal").await?; + /// println!("Space created successfully!"); + /// # Ok(()) + /// # } + /// ``` + #[must_use = "Space was initialized but never used"] + pub async fn init(path: impl AsRef + Send + Sync, name: &str) -> Result { + let fs = LocalFilesystem; + let inner = DefaultSpace::init(fs, path, name).await?; + + Ok(Self { inner }) + } + + /// Load an existing space. + /// + /// Spaces can be loaded either by their registered name or by an + /// explicit filesystem path. The `locator` parameter accepts anything + /// that can be converted into a [`Locator`], including: + /// + /// - `&str` or `String` — interpreted as a space name + /// - `&Path` or `PathBuf` — interpreted as a filesystem path + /// + /// # Arguments + /// + /// * `locator` - Identifies which space to load. See [`Locator`] for + /// the different ways to specify a space. + /// + /// # Errors + /// + /// Returns an error if: + /// - The space cannot be found (name not registered or path doesn't exist) + /// - The space configuration is missing or corrupted + /// - The filesystem cannot be read (e.g., permission denied) + /// + /// # Examples + /// + /// ```no_run + /// use flow_core::Space; + /// use std::path::PathBuf; + /// + /// # async fn example() -> miette::Result<()> { + /// // Load by name + /// let space = Space::load("personal").await?; + /// + /// // Load by path + /// let space = Space::load(PathBuf::from("./my-notes")).await?; + /// # Ok(()) + /// # } + /// ``` + #[must_use = "Space was loaded but never used"] + pub async fn load(locator: impl Into) -> Result { + let fs = LocalFilesystem; + let inner = DefaultSpace::load(fs, locator.into()).await?; + + Ok(Self { inner }) + } +} + +#[cfg(test)] +mod tests { + // TODO: Add integration tests for Space + // + // Tests should cover: + // - Initializing a new space + // - Loading an existing space by name + // - Loading an existing space by path + // - Error cases (space not found, already exists, etc.) +} diff --git a/crates/flow-core/src/space/default.rs b/crates/flow-core/src/space/default.rs new file mode 100644 index 0000000..5fb2dec --- /dev/null +++ b/crates/flow-core/src/space/default.rs @@ -0,0 +1,235 @@ +//! Default implementation of the [`Space`] trait. +//! +//! This module provides [`DefaultSpace`], the standard implementation of +//! the space trait used in production. It is generic over the filesystem +//! implementation to allow for dependency injection during testing. +//! +//! # Overview +//! +//! The [`DefaultSpace`] struct manages the lifecycle of a Flow space, +//! including: +//! +//! - Creating the directory structure (`.flow/`, `journal/`) +//! - Writing and reading space metadata (`space.json`) +//! - Managing the Loro CRDT document for collaboration +//! +//! # Directory Structure +//! +//! When a space is initialized, the following structure is created: +//! +//! ```text +//! my-space/ +//! ├── .flow/ +//! │ ├── space.json # Space metadata +//! │ └── space.loro # Loro CRDT document snapshot +//! └── journal/ # Directory for journal entries +//! ``` +//! +//! # Examples +//! +//! ```ignore +//! use flow_core::filesystem::LocalFilesystem; +//! use flow_core::space::default::DefaultSpace; +//! use flow_core::space::traits::Space; +//! +//! // Initialize a new space +//! let fs = LocalFilesystem; +//! let space = DefaultSpace::init(fs, "./my-space", "personal").await?; +//! ``` + +use std::path::Path; + +use loro::LoroDoc; +use miette::{ensure, IntoDiagnostic, Result}; + +use crate::{ + errors::Error, + filesystem::Filesystem, + space::{traits::Space, Locator, Metadata}, +}; + +/// The directory name where Flow stores space metadata. +/// +/// This hidden directory contains all Flow-specific files, keeping +/// the user's content directory clean. +const FLOW_DIR: &str = ".flow"; + +/// The filename for space metadata (JSON format). +/// +/// This file stores the space name, version, and other metadata +/// needed to identify and manage the space. +const METADATA_FILE: &str = "space.json"; + +/// The filename for the Loro CRDT document snapshot. +/// +/// This file contains a binary snapshot of the Loro CRDT document, +/// which enables offline-first collaboration and conflict resolution. +const DOCUMENT_FILE: &str = "space.loro"; + +/// The directory name for journal entries. +/// +/// Journal entries are stored as individual files in this directory, +/// organized by date or other criteria. +const JOURNAL_DIR: &str = "journal"; + +/// The default implementation of a Flow space. +/// +/// `DefaultSpace` is generic over the filesystem implementation, allowing +/// different storage backends to be used. In production, this is typically +/// [`LocalFilesystem`](crate::filesystem::LocalFilesystem), while tests +/// can inject mock implementations. +/// +/// # Type Parameters +/// +/// * `F` - The filesystem implementation to use for all I/O operations. +/// Must implement [`Filesystem`] and be thread-safe (`Send + Sync`). +/// +/// # Fields +/// +/// * `fs` - The filesystem backend for all I/O operations. +/// * `metadata` - Space metadata including name and version. +/// * `doc` - The Loro CRDT document for collaborative editing. +/// +/// # Thread Safety +/// +/// `DefaultSpace` is `Send` and `Sync` when the filesystem implementation +/// is also `Send` and `Sync`, making it safe for use in async contexts. +/// +/// # Examples +/// +/// Initializing a new space: +/// +/// ```ignore +/// use flow_core::filesystem::LocalFilesystem; +/// use flow_core::space::default::DefaultSpace; +/// use flow_core::space::traits::Space; +/// +/// let fs = LocalFilesystem; +/// let space = DefaultSpace::init(fs, "./my-space", "personal").await?; +/// ``` +/// +/// Using a mock filesystem for testing: +/// +/// ```ignore +/// use flow_core::space::default::DefaultSpace; +/// use flow_core::space::traits::Space; +/// +/// let mock_fs = MockFilesystem::new(); +/// let space = DefaultSpace::init(mock_fs, "./test-space", "test").await?; +/// ``` +#[allow(dead_code)] // TODO: Remove once methods using these fields are implemented +pub struct DefaultSpace { + /// The filesystem implementation used for all I/O operations. + /// + /// This is injected at construction time, allowing different backends + /// to be used in production vs. testing environments. + fs: F, + + /// Metadata of the space. + /// + /// Contains the space name, version, and other identifying information. + /// This is persisted to `.flow/space.json`. + metadata: Metadata, + + /// Loro CRDT document for offline and local-first collaboration. + /// + /// This document stores the space's content in a format that supports + /// conflict-free merging, enabling offline editing and real-time + /// collaboration. + doc: LoroDoc, +} + +impl Space for DefaultSpace { + type Fs = F; + + /// Initializes a new space at the given path. + /// + /// This implementation performs the following steps: + /// + /// 1. Validates that the path exists and is an empty directory + /// 2. Creates the `.flow/` directory for metadata storage + /// 3. Creates the `journal/` directory for journal entries + /// 4. Writes the space metadata to `.flow/space.json` + /// 5. Creates and persists an empty Loro CRDT document + /// + /// # Implementation Notes + /// + /// - The space metadata is serialized as JSON using [`serde_json`]. + /// - The Loro document is exported as a binary snapshot for efficiency. + /// - All filesystem operations use the injected `fs` implementation. + async fn init(fs: Self::Fs, path: impl AsRef + Send + Sync, name: &str) -> Result + where + Self: Sized, + { + let path = path.as_ref(); + let exists = fs.exists(path).await?; + ensure!(exists, Error::PathNotFound(path.to_path_buf())); + + let is_dir = fs.is_dir(path).await?; + ensure!(is_dir, Error::NotADirectory(path.to_path_buf())); + + let flow_dir = path.join(FLOW_DIR); + let is_empty = fs.is_dir_empty(path).await?; + if !is_empty { + let has_space = fs.exists(&flow_dir).await?; + if has_space { + return Err(Error::AlreadyExists(path.to_path_buf()).into()); + } + return Err(Error::DirectoryNotEmpty(path.to_path_buf()).into()); + } + + let journal_dir = path.join(JOURNAL_DIR); + fs.create_dir(&flow_dir).await?; + fs.create_dir(&journal_dir).await?; + + let metadata = Metadata { + name: name.to_owned(), + version: env!("CARGO_PKG_VERSION").to_string(), + }; + let metadata_json = serde_json::to_string(&metadata).into_diagnostic()?; + let metadata_path = flow_dir.join(METADATA_FILE); + fs.write(&metadata_path, metadata_json.as_bytes()).await?; + + let doc = LoroDoc::new(); + let doc_snapshot = doc.export(loro::ExportMode::Snapshot).into_diagnostic()?; + let doc_path = flow_dir.join(DOCUMENT_FILE); + fs.write(&doc_path, &doc_snapshot).await?; + + Ok(Self { fs, metadata, doc }) + } + + /// Loads an existing space from the given locator. + /// + /// # Implementation Notes + /// + /// This method will: + /// + /// 1. Resolve the [`Locator`] to a filesystem path + /// 2. Read and deserialize the space metadata from `.flow/space.json` + /// 3. Load the Loro CRDT document from `.flow/space.loro` + /// + /// # Unimplemented + /// + /// This method is not yet implemented and will panic if called. + async fn load(_fs: Self::Fs, _locator: Locator) -> Result + where + Self: Sized, + { + todo!(); + + // TODO: Implement space loading: + // 1. Resolve the locator to a path + // 2. Read and validate the space configuration + // 3. Initialize the space struct + + // let doc = LoroDoc::new(); + // let space = Self { fs, doc }; + + // Ok(space) + } +} + +#[cfg(test)] +mod tests { + // TODO: Add tests with a mock filesystem implementation +} diff --git a/crates/flow-core/src/space/locator.rs b/crates/flow-core/src/space/locator.rs new file mode 100644 index 0000000..a03a1c6 --- /dev/null +++ b/crates/flow-core/src/space/locator.rs @@ -0,0 +1,156 @@ +//! Space locator types. +//! +//! This module provides [`Locator`], a flexible way to identify spaces +//! either by name or by filesystem path. +//! +//! # Overview +//! +//! When working with Flow spaces, you often need to specify which space +//! to operate on. The [`Locator`] enum provides two ways to do this: +//! +//! - **By name**: Use a human-readable name that is resolved from the +//! global Flow configuration (e.g., `"personal"`, `"work"`). +//! - **By path**: Use an explicit filesystem path to the space directory. +//! +//! # Type Conversions +//! +//! `Locator` implements [`From`] for common string and path types, making +//! it easy to use with APIs that accept `impl Into`: +//! +//! - `String` and `&str` are interpreted as space names. +//! - `PathBuf` and `&Path` are interpreted as filesystem paths. + +use std::path::{Path, PathBuf}; + +/// A way to locate a [`Space`](super::Space). +/// +/// Spaces can be identified either by their human-readable name +/// (which is resolved from a configuration directory) or by +/// their explicit filesystem path. +/// +/// # Examples +/// +/// ``` +/// use flow_core::Locator; +/// use std::path::PathBuf; +/// +/// // From a string (interpreted as name) +/// let loc: Locator = "my-notes".into(); +/// +/// // From a PathBuf (interpreted as path) +/// let loc: Locator = PathBuf::from("/home/user/notes").into(); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Locator { + /// Locate a space by its registered name. + /// + /// The name is resolved from the global Flow configuration file, + /// which maps names to filesystem paths. This is the preferred + /// way to reference spaces in most user-facing contexts. + /// + /// # Examples + /// + /// ``` + /// use flow_core::Locator; + /// + /// let locator = Locator::Name("personal".to_string()); + /// ``` + Name(String), + + /// Locate a space by its explicit filesystem path. + /// + /// This bypasses the global configuration and directly references + /// a space directory. Useful for working with spaces that haven't + /// been registered, or for tools that operate on arbitrary paths. + /// + /// # Examples + /// + /// ``` + /// use std::path::PathBuf; + /// use flow_core::Locator; + /// + /// let locator = Locator::Path(PathBuf::from("/home/user/notes")); + /// ``` + Path(PathBuf), +} + +impl From for Locator { + /// Converts a [`String`] into a [`Locator::Name`]. + /// + /// # Arguments + /// + /// * `s` - The space name to convert. + /// + /// # Examples + /// + /// ``` + /// use flow_core::Locator; + /// + /// let locator: Locator = String::from("personal").into(); + /// assert_eq!(locator, Locator::Name("personal".to_string())); + /// ``` + fn from(s: String) -> Self { + Self::Name(s) + } +} + +impl From<&str> for Locator { + /// Converts a string slice into a [`Locator::Name`]. + /// + /// # Arguments + /// + /// * `s` - The space name to convert. + /// + /// # Examples + /// + /// ``` + /// use flow_core::Locator; + /// + /// let locator: Locator = "work".into(); + /// assert_eq!(locator, Locator::Name("work".to_string())); + /// ``` + fn from(s: &str) -> Self { + Self::Name(s.to_owned()) + } +} + +impl From for Locator { + /// Converts a [`PathBuf`] into a [`Locator::Path`]. + /// + /// # Arguments + /// + /// * `path` - The filesystem path to convert. + /// + /// # Examples + /// + /// ``` + /// use std::path::PathBuf; + /// use flow_core::Locator; + /// + /// let locator: Locator = PathBuf::from("/home/user/notes").into(); + /// assert_eq!(locator, Locator::Path(PathBuf::from("/home/user/notes"))); + /// ``` + fn from(path: PathBuf) -> Self { + Self::Path(path) + } +} + +impl From<&Path> for Locator { + /// Converts a [`Path`] reference into a [`Locator::Path`]. + /// + /// # Arguments + /// + /// * `path` - The filesystem path to convert. + /// + /// # Examples + /// + /// ``` + /// use std::path::Path; + /// use flow_core::Locator; + /// + /// let locator: Locator = Path::new("/home/user/notes").into(); + /// ``` + fn from(path: &Path) -> Self { + Self::Path(path.to_path_buf()) + } +} diff --git a/crates/flow-core/src/space/metadata.rs b/crates/flow-core/src/space/metadata.rs new file mode 100644 index 0000000..2a70c4f --- /dev/null +++ b/crates/flow-core/src/space/metadata.rs @@ -0,0 +1,80 @@ +//! Space metadata types. +//! +//! This module provides [`Metadata`], which contains information about +//! a space such as its name and the Flow version that created it. +//! +//! # Overview +//! +//! Every Flow space has an associated metadata file (`.flow/space.json`) +//! that stores essential information about the space. This metadata is +//! read when loading a space and written when initializing a new one. +//! +//! # Serialization +//! +//! The [`Metadata`] struct is serialized to JSON using [`serde`]. The +//! format is designed to be human-readable and forward-compatible, +//! allowing future versions of Flow to add new fields without breaking +//! existing spaces. + +use serde::{Deserialize, Serialize}; + +/// Metadata about a Flow space. +/// +/// This struct is serialized to JSON and stored in the `.flow/space.json` +/// file within each space. It contains essential information needed to +/// identify and manage the space. +/// +/// # Fields +/// +/// * `name` - The human-readable name of the space, used for identification. +/// * `version` - The Flow version that created or last migrated this space. +/// +/// # Serialization Format +/// +/// The metadata is stored as JSON: +/// +/// ```json +/// { +/// "name": "personal", +/// "version": "0.1.0" +/// } +/// ``` +/// +/// # Examples +/// +/// Creating metadata for a new space: +/// +/// ```ignore +/// use flow_core::space::Metadata; +/// +/// let metadata = Metadata { +/// name: "personal".to_string(), +/// version: "0.1.0".to_string(), +/// }; +/// ``` +/// +/// Serializing metadata to JSON: +/// +/// ```ignore +/// use flow_core::space::Metadata; +/// +/// let metadata = Metadata { +/// name: "work".to_string(), +/// version: "0.1.0".to_string(), +/// }; +/// +/// let json = serde_json::to_string_pretty(&metadata).unwrap(); +/// assert!(json.contains("\"name\": \"work\"")); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Metadata { + /// The human-readable name of the space. + /// + /// This name is used to identify the space in commands like `flow open `. + pub name: String, + + /// The version of Flow that created this space. + /// + /// This can be used for future migrations if the space format changes. + pub version: String, +} diff --git a/crates/flow-core/src/space/traits.rs b/crates/flow-core/src/space/traits.rs new file mode 100644 index 0000000..eab19bf --- /dev/null +++ b/crates/flow-core/src/space/traits.rs @@ -0,0 +1,119 @@ +//! Internal trait definitions for space implementations. +//! +//! This module contains the internal traits that define the contract +//! for space implementations. These traits are not part of the public API. + +use std::path::Path; + +use miette::Result; + +use crate::{filesystem::Filesystem, space::locator::Locator}; + +/// Internal trait defining the core operations for a space. +/// +/// This trait is implemented by [`DefaultSpace`] and potentially other +/// space implementations. It is not exposed publicly; instead, the public +/// [`Space`](super::Space) struct wraps implementations of this trait. +/// +/// # Design Notes +/// +/// The trait is generic over the filesystem implementation (`Fs`), which +/// enables dependency injection for testing. Production code uses +/// [`LocalFilesystem`](crate::filesystem::LocalFilesystem), while tests +/// can provide mock implementations. +/// +/// # Implementors +/// +/// - [`DefaultSpace`](super::default::DefaultSpace) - The standard implementation. +/// +/// # Examples +/// +/// Implementing a custom space type: +/// +/// ```ignore +/// use flow_core::filesystem::Filesystem; +/// use flow_core::space::{Locator, traits::Space}; +/// use miette::Result; +/// use std::path::Path; +/// +/// struct CustomSpace { +/// fs: F, +/// // ... other fields +/// } +/// +/// impl Space for CustomSpace { +/// type Fs = F; +/// +/// async fn init(fs: Self::Fs, path: impl AsRef, name: &str) -> Result { +/// // Custom initialization logic +/// todo!() +/// } +/// +/// async fn load(fs: Self::Fs, locator: Locator) -> Result { +/// // Custom loading logic +/// todo!() +/// } +/// } +/// ``` +pub trait Space: Send + Sync { + /// The filesystem implementation used by this space. + /// + /// This associated type allows space implementations to be generic + /// over different filesystem backends. The filesystem must implement + /// [`Filesystem`] and be thread-safe (`Send + Sync`). + /// + /// # Examples + /// + /// ```ignore + /// use flow_core::filesystem::LocalFilesystem; + /// + /// // In production, use LocalFilesystem + /// type Fs = LocalFilesystem; + /// + /// // In tests, use a mock filesystem + /// type Fs = MockFilesystem; + /// ``` + type Fs: Filesystem + Send + Sync; + + /// Initialize a new space at the given path. + /// + /// This creates the necessary directory structure and configuration + /// files for a new space. The space will be registered with the given + /// name for future lookup. + /// + /// # Arguments + /// + /// * `fs` - The filesystem implementation to use. + /// * `path` - The directory path where the space will be created. + /// * `name` - A human-readable name for the space. + /// + /// # Errors + /// + /// Returns an error if: + /// - The path already contains a space. + /// - The directory cannot be created. + /// - The configuration file cannot be written. + async fn init(fs: Self::Fs, path: impl AsRef + Send + Sync, name: &str) -> Result + where + Self: Sized; + + /// Load an existing space from the given locator. + /// + /// The locator can specify either a space name (which is resolved + /// from the global configuration) or an explicit filesystem path. + /// + /// # Arguments + /// + /// * `fs` - The filesystem implementation to use. + /// * `locator` - Identifies which space to load. + /// + /// # Errors + /// + /// Returns an error if: + /// - The space cannot be found. + /// - The space configuration is invalid or corrupted. + /// - The filesystem cannot be read. + async fn load(fs: Self::Fs, locator: Locator) -> Result + where + Self: Sized; +} diff --git a/crates/flow/src/main.rs b/crates/flow/src/main.rs index b80392b..e1edd47 100644 --- a/crates/flow/src/main.rs +++ b/crates/flow/src/main.rs @@ -63,7 +63,7 @@ async fn main() -> Result<()> { Some(Commands::Gui) => flow_gui::run().await?, #[cfg(feature = "server")] Some(Commands::Serve) => flow_server::run().await?, - Some(Commands::Cli(cmd)) => flow_cli::run(&cmd)?, + Some(Commands::Cli(cmd)) => flow_cli::run(&cmd).await?, None => { // TODO: Maybe later we can just launch the UI the user selected in the configuration. From e224a98a632a28d696a10bba8172e44d9fc7fe66 Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Sat, 17 Jan 2026 22:04:20 +0100 Subject: [PATCH 02/19] chore: Add BSL-1.0 to allowed licenses Also allowed unused license execption, since it only originates from the project's crates. --- deny.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/deny.toml b/deny.toml index 44b2ee7..3b42bef 100644 --- a/deny.toml +++ b/deny.toml @@ -23,6 +23,7 @@ ignore = [] [licenses] confidence-threshold = 0.8 unused-allowed-license = "allow" +unused-license-exception = "allow" allow = [ "MIT", "Apache-2.0", @@ -36,6 +37,7 @@ allow = [ "Unicode-3.0", "CC0-1.0", "MPL-2.0", + "BSL-1.0", ] exceptions = [ { name = "flow", allow = ["AGPL-3.0-or-later"] }, From 020042603b221fd46077eecf3a61095bac3f63d6 Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Sat, 17 Jan 2026 22:04:52 +0100 Subject: [PATCH 03/19] docs(license): Add clearification about the exception for AGPL-3.0 --- deny.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deny.toml b/deny.toml index 3b42bef..c0c6351 100644 --- a/deny.toml +++ b/deny.toml @@ -39,6 +39,9 @@ allow = [ "MPL-2.0", "BSL-1.0", ] +# Workspace crates use AGPL-3.0-or-later. Others can use and modify the code, +# but if they run a modified version as a network service (e.g., SaaS), they +# must make their source code available to users (see AGPL Section 13). exceptions = [ { name = "flow", allow = ["AGPL-3.0-or-later"] }, { name = "flow-app", allow = ["AGPL-3.0-or-later"] }, From 0225eca0707d8d615569d201711ab35701c9c393 Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Sat, 17 Jan 2026 22:13:41 +0100 Subject: [PATCH 04/19] refactor(space): Use `impl Into` instead of `&str` for space name --- crates/flow-core/src/space.rs | 2 +- crates/flow-core/src/space/default.rs | 4 ++-- crates/flow-core/src/space/traits.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/flow-core/src/space.rs b/crates/flow-core/src/space.rs index 7d75071..95d728b 100644 --- a/crates/flow-core/src/space.rs +++ b/crates/flow-core/src/space.rs @@ -136,7 +136,7 @@ impl Space { /// # } /// ``` #[must_use = "Space was initialized but never used"] - pub async fn init(path: impl AsRef + Send + Sync, name: &str) -> Result { + pub async fn init(path: impl AsRef + Send + Sync, name: impl Into) -> Result { let fs = LocalFilesystem; let inner = DefaultSpace::init(fs, path, name).await?; diff --git a/crates/flow-core/src/space/default.rs b/crates/flow-core/src/space/default.rs index 5fb2dec..510f071 100644 --- a/crates/flow-core/src/space/default.rs +++ b/crates/flow-core/src/space/default.rs @@ -157,7 +157,7 @@ impl Space for DefaultSpace { /// - The space metadata is serialized as JSON using [`serde_json`]. /// - The Loro document is exported as a binary snapshot for efficiency. /// - All filesystem operations use the injected `fs` implementation. - async fn init(fs: Self::Fs, path: impl AsRef + Send + Sync, name: &str) -> Result + async fn init(fs: Self::Fs, path: impl AsRef + Send + Sync, name: impl Into) -> Result where Self: Sized, { @@ -183,7 +183,7 @@ impl Space for DefaultSpace { fs.create_dir(&journal_dir).await?; let metadata = Metadata { - name: name.to_owned(), + name: name.into(), version: env!("CARGO_PKG_VERSION").to_string(), }; let metadata_json = serde_json::to_string(&metadata).into_diagnostic()?; diff --git a/crates/flow-core/src/space/traits.rs b/crates/flow-core/src/space/traits.rs index eab19bf..9cf3a9f 100644 --- a/crates/flow-core/src/space/traits.rs +++ b/crates/flow-core/src/space/traits.rs @@ -93,7 +93,7 @@ pub trait Space: Send + Sync { /// - The path already contains a space. /// - The directory cannot be created. /// - The configuration file cannot be written. - async fn init(fs: Self::Fs, path: impl AsRef + Send + Sync, name: &str) -> Result + async fn init(fs: Self::Fs, path: impl AsRef + Send + Sync, name: impl Into) -> Result where Self: Sized; From ef26534381cdf63d094be741940f213d50a11dff Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Sat, 17 Jan 2026 22:17:13 +0100 Subject: [PATCH 05/19] docs(changelog): Add summary of the space and filesystem modules --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 232bab8..92daf04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Space module with trait-based abstraction pattern for easy testing and extensibility +- Filesystem module with local filesystem implementation - Initial project structure with workspace layout - CLI foundation with clap argument parsing - TUI crate placeholder (feature-gated) From 8dc06b9a69a53c5dc4f7338cc5ddcc799e4c4fd8 Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Sun, 18 Jan 2026 15:23:41 +0100 Subject: [PATCH 06/19] docs: Changed edit URL to use main instead of master branch --- docs/book.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/book.toml b/docs/book.toml index d4a292f..93e50ad 100644 --- a/docs/book.toml +++ b/docs/book.toml @@ -17,7 +17,7 @@ default-theme = "navy" preferred-dark-theme = "navy" smart-punctuation = true git-repository-url = "https://github.com/mrbandler/flow" -edit-url-template = "https://github.com/mrbandler/flow/edit/master/docs/{path}" +edit-url-template = "https://github.com/mrbandler/flow/edit/main/docs/{path}" site-url = "/flow/" cname = "" From 2ae5c32de70f9c297df9a1bbae6980e78413cad8 Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Sun, 18 Jan 2026 15:37:01 +0100 Subject: [PATCH 07/19] feat(space): Add loading via path locator The path locator will be implemented once the configuration system is in place. --- crates/flow-core/src/space/default.rs | 41 +++++++++++++++++++-------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/crates/flow-core/src/space/default.rs b/crates/flow-core/src/space/default.rs index 510f071..f97195c 100644 --- a/crates/flow-core/src/space/default.rs +++ b/crates/flow-core/src/space/default.rs @@ -37,7 +37,7 @@ //! let space = DefaultSpace::init(fs, "./my-space", "personal").await?; //! ``` -use std::path::Path; +use std::path::{Path, PathBuf}; use loro::LoroDoc; use miette::{ensure, IntoDiagnostic, Result}; @@ -125,6 +125,9 @@ pub struct DefaultSpace { /// to be used in production vs. testing environments. fs: F, + /// Path of the space. + path: PathBuf, + /// Metadata of the space. /// /// Contains the space name, version, and other identifying information. @@ -195,7 +198,12 @@ impl Space for DefaultSpace { let doc_path = flow_dir.join(DOCUMENT_FILE); fs.write(&doc_path, &doc_snapshot).await?; - Ok(Self { fs, metadata, doc }) + Ok(Self { + fs, + path: path.to_path_buf(), + metadata, + doc, + }) } /// Loads an existing space from the given locator. @@ -211,21 +219,30 @@ impl Space for DefaultSpace { /// # Unimplemented /// /// This method is not yet implemented and will panic if called. - async fn load(_fs: Self::Fs, _locator: Locator) -> Result + async fn load(fs: Self::Fs, locator: Locator) -> Result where Self: Sized, { - todo!(); - - // TODO: Implement space loading: - // 1. Resolve the locator to a path - // 2. Read and validate the space configuration - // 3. Initialize the space struct + let path = match locator { + Locator::Name(_name) => todo!("Look up the path through the name of the space"), + Locator::Path(path) => path, + }; - // let doc = LoroDoc::new(); - // let space = Self { fs, doc }; + let flow_dir = path.join(FLOW_DIR); + let metadata_path = flow_dir.join(METADATA_FILE); + let metadata_json = fs.read(&metadata_path).await?; + let metadata = serde_json::from_slice::(&metadata_json).into_diagnostic()?; // TODO: Create custom error for this? - // Ok(space) + let doc_path = flow_dir.join(DOCUMENT_FILE); + let doc_snapshot = fs.read(&doc_path).await?; + let doc = LoroDoc::from_snapshot(&doc_snapshot).into_diagnostic()?; // TODO: Create custom error for this? + + Ok(Self { + fs, + path, + metadata, + doc, + }) } } From 5b7e385201323c72355774d1a6c56027eb4ea118 Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Sun, 18 Jan 2026 15:41:55 +0100 Subject: [PATCH 08/19] feat(space): Add `read_to_string` to filesystem trait and using it in space default impl --- crates/flow-core/src/filesystem/local.rs | 11 ++++++++++- crates/flow-core/src/filesystem/traits.rs | 12 +++++++++++- crates/flow-core/src/space/default.rs | 6 +++--- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/flow-core/src/filesystem/local.rs b/crates/flow-core/src/filesystem/local.rs index aa4d60b..c43c7ff 100644 --- a/crates/flow-core/src/filesystem/local.rs +++ b/crates/flow-core/src/filesystem/local.rs @@ -7,7 +7,7 @@ use std::path::Path; use miette::Result; -use tokio::fs::{create_dir, metadata, read, read_dir, try_exists, write}; +use tokio::fs::{create_dir, metadata, read, read_dir, read_to_string, try_exists, write}; use crate::errors::Error; use crate::filesystem::traits::Filesystem; @@ -105,6 +105,15 @@ impl Filesystem for LocalFilesystem { let path = path.as_ref(); read(path).await.map_err(|e| Error::Io(e).into()) } + + /// Reads the entire file contents using [`tokio::fs::read_to_string`]. + /// + /// The entire file is read into memory. For large files, consider + /// using streaming APIs instead. + async fn read_to_string(&self, path: impl AsRef) -> Result { + let path = path.as_ref(); + read_to_string(path).await.map_err(|e| Error::Io(e).into()) + } } #[cfg(test)] diff --git a/crates/flow-core/src/filesystem/traits.rs b/crates/flow-core/src/filesystem/traits.rs index 315d05b..604a1aa 100644 --- a/crates/flow-core/src/filesystem/traits.rs +++ b/crates/flow-core/src/filesystem/traits.rs @@ -112,6 +112,16 @@ pub trait Filesystem: Send + Sync { /// # Errors /// /// Returns an error if the file does not exist or cannot be read. - #[allow(dead_code)] // TODO: Remove once `Space::load` is implemented async fn read(&self, path: impl AsRef) -> Result>; + + /// Reads the entire contents of a file into a string. + /// + /// # Arguments + /// + /// * `path` - The path to the file to read. + /// + /// # Errors + /// + /// Returns an error if the file does not exist or cannot be read. + async fn read_to_string(&self, path: impl AsRef) -> Result; } diff --git a/crates/flow-core/src/space/default.rs b/crates/flow-core/src/space/default.rs index f97195c..d6785c3 100644 --- a/crates/flow-core/src/space/default.rs +++ b/crates/flow-core/src/space/default.rs @@ -117,7 +117,7 @@ const JOURNAL_DIR: &str = "journal"; /// let mock_fs = MockFilesystem::new(); /// let space = DefaultSpace::init(mock_fs, "./test-space", "test").await?; /// ``` -#[allow(dead_code)] // TODO: Remove once methods using these fields are implemented +#[allow(dead_code)] pub struct DefaultSpace { /// The filesystem implementation used for all I/O operations. /// @@ -230,8 +230,8 @@ impl Space for DefaultSpace { let flow_dir = path.join(FLOW_DIR); let metadata_path = flow_dir.join(METADATA_FILE); - let metadata_json = fs.read(&metadata_path).await?; - let metadata = serde_json::from_slice::(&metadata_json).into_diagnostic()?; // TODO: Create custom error for this? + let metadata_json = fs.read_to_string(&metadata_path).await?; + let metadata = serde_json::from_str::(&metadata_json).into_diagnostic()?; // TODO: Create custom error for this? let doc_path = flow_dir.join(DOCUMENT_FILE); let doc_snapshot = fs.read(&doc_path).await?; From 0ae346768a90204348555726b1006a4ded51f773 Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Sun, 18 Jan 2026 22:34:43 +0100 Subject: [PATCH 09/19] refactor(space): Use `_` for Space trait import in module root --- crates/flow-core/src/space.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/flow-core/src/space.rs b/crates/flow-core/src/space.rs index 95d728b..1ac89f9 100644 --- a/crates/flow-core/src/space.rs +++ b/crates/flow-core/src/space.rs @@ -60,7 +60,7 @@ use miette::Result; use crate::filesystem::LocalFilesystem; use crate::space::default::DefaultSpace; -use crate::space::traits::Space as SpaceTrait; +use crate::space::traits::Space as _; mod default; mod locator; From 72946c69cbef45abc525d21058ea47ec7c2af1f2 Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Sun, 18 Jan 2026 22:35:44 +0100 Subject: [PATCH 10/19] refactor(errors): Simplify error names --- crates/flow-core/src/errors.rs | 34 +++++++++++++-------------- crates/flow-core/src/space/default.rs | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/crates/flow-core/src/errors.rs b/crates/flow-core/src/errors.rs index 82fe2b8..81b947d 100644 --- a/crates/flow-core/src/errors.rs +++ b/crates/flow-core/src/errors.rs @@ -115,23 +115,7 @@ pub enum Error { url(docsrs), help("Make sure the directory exists before initializing a space") )] - PathNotFound(PathBuf), - - /// The specified path is not a directory. - /// - /// This error occurs when a path exists but is a file rather than - /// a directory. Spaces can only be initialized in directories. - /// - /// # Error Code - /// - /// `flow::not_a_directory` - /// - /// # Fields - /// - /// * `0` - The path that is not a directory. - #[error("Path is not a directory: {0}")] - #[diagnostic(code(flow::not_a_directory), url(docsrs), help("Make sure the path is a directory"))] - NotADirectory(PathBuf), + NotFound(PathBuf), /// A space already exists at the specified path. /// @@ -154,6 +138,22 @@ pub enum Error { )] AlreadyExists(PathBuf), + /// The specified path is not a directory. + /// + /// This error occurs when a path exists but is a file rather than + /// a directory. Spaces can only be initialized in directories. + /// + /// # Error Code + /// + /// `flow::not_a_directory` + /// + /// # Fields + /// + /// * `0` - The path that is not a directory. + #[error("Path is not a directory: {0}")] + #[diagnostic(code(flow::not_a_directory), url(docsrs), help("Make sure the path is a directory"))] + NotADirectory(PathBuf), + /// The directory is not empty. /// /// This error occurs when attempting to initialize a space in a diff --git a/crates/flow-core/src/space/default.rs b/crates/flow-core/src/space/default.rs index d6785c3..b6a6501 100644 --- a/crates/flow-core/src/space/default.rs +++ b/crates/flow-core/src/space/default.rs @@ -166,7 +166,7 @@ impl Space for DefaultSpace { { let path = path.as_ref(); let exists = fs.exists(path).await?; - ensure!(exists, Error::PathNotFound(path.to_path_buf())); + ensure!(exists, Error::NotFound(path.to_path_buf())); let is_dir = fs.is_dir(path).await?; ensure!(is_dir, Error::NotADirectory(path.to_path_buf())); From 2ddd39a01967c5c59164035ae61c85413d2c3c3b Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Sun, 18 Jan 2026 22:36:08 +0100 Subject: [PATCH 11/19] refactor(space): Use `to_string_pretty` when serializating metadata --- crates/flow-core/src/space/default.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/flow-core/src/space/default.rs b/crates/flow-core/src/space/default.rs index b6a6501..dda23dc 100644 --- a/crates/flow-core/src/space/default.rs +++ b/crates/flow-core/src/space/default.rs @@ -189,7 +189,7 @@ impl Space for DefaultSpace { name: name.into(), version: env!("CARGO_PKG_VERSION").to_string(), }; - let metadata_json = serde_json::to_string(&metadata).into_diagnostic()?; + let metadata_json = serde_json::to_string_pretty(&metadata).into_diagnostic()?; let metadata_path = flow_dir.join(METADATA_FILE); fs.write(&metadata_path, metadata_json.as_bytes()).await?; From b905e7796a223f29954e8d3fac435a9e9acd922c Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Sun, 18 Jan 2026 22:42:41 +0100 Subject: [PATCH 12/19] feat(cli): Add 'init' sub-command Also introduces the command trait and common global arguments for each sub-command. --- Cargo.lock | 292 +++++++++++++++++++++++++++ crates/flow-cli/Cargo.toml | 4 + crates/flow-cli/src/commands.rs | 35 ++++ crates/flow-cli/src/commands/init.rs | 101 +++++++++ crates/flow-cli/src/common.rs | 132 ++++++++++++ crates/flow-cli/src/errors.rs | 13 ++ crates/flow-cli/src/extensions.rs | 21 ++ crates/flow-cli/src/lib.rs | 14 +- 8 files changed, 607 insertions(+), 5 deletions(-) create mode 100644 crates/flow-cli/src/commands.rs create mode 100644 crates/flow-cli/src/commands/init.rs create mode 100644 crates/flow-cli/src/common.rs create mode 100644 crates/flow-cli/src/errors.rs create mode 100644 crates/flow-cli/src/extensions.rs diff --git a/Cargo.lock b/Cargo.lock index 5bed719..c231a2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,6 +39,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -213,6 +222,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.54" @@ -268,6 +290,34 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "console" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -283,6 +333,33 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -339,6 +416,28 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.111", +] + [[package]] name = "diff" version = "0.1.13" @@ -355,6 +454,21 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -373,6 +487,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "ensure-cov" version = "0.1.0" @@ -431,6 +551,12 @@ dependencies = [ "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 = "find-msvc-tools" version = "0.1.8" @@ -465,8 +591,12 @@ name = "flow-cli" version = "0.1.0" dependencies = [ "clap", + "console", "flow-core", + "inquire", "miette", + "serde", + "serde_json", "thiserror 2.0.17", "tokio", ] @@ -519,6 +649,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "generator" version = "0.8.8" @@ -659,6 +798,30 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -680,6 +843,22 @@ dependencies = [ "version_check", ] +[[package]] +name = "inquire" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae51d5da01ce7039024fbdec477767c102c454dbdb09d4e2a432ece705b1b25d" +dependencies = [ + "bitflags", + "chrono", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "tempfile", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "is_ci" version = "1.2.0" @@ -750,6 +929,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -994,6 +1179,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1470,6 +1656,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1573,6 +1780,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.60.2", +] + [[package]] name = "terminal_size" version = "0.4.3" @@ -1761,6 +1981,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.1.14" @@ -1851,6 +2077,63 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1866,6 +2149,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/crates/flow-cli/Cargo.toml b/crates/flow-cli/Cargo.toml index 91f6633..33e0551 100644 --- a/crates/flow-cli/Cargo.toml +++ b/crates/flow-cli/Cargo.toml @@ -18,6 +18,10 @@ clap = { workspace = true } tokio = { workspace = true } miette = { workspace = true } thiserror = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +inquire = { version = "0.9.2", features = ["date", "editor"] } +console = "0.16.2" [lints] workspace = true diff --git a/crates/flow-cli/src/commands.rs b/crates/flow-cli/src/commands.rs new file mode 100644 index 0000000..1e2ff7e --- /dev/null +++ b/crates/flow-cli/src/commands.rs @@ -0,0 +1,35 @@ +use miette::Result; + +use crate::common::GlobalArgs; + +pub mod init; + +pub trait Command: Sized { + type Args; + type Output: serde::Serialize; + + fn new(args: Self::Args) -> Self; + + fn globals(&self) -> &GlobalArgs; + + async fn interactive(&mut self) -> Result<()>; + + async fn execute(&mut self) -> Result; + + fn finalize(&self, output: &Self::Output); + + async fn run(&mut self) -> Result<()> { + if !self.globals().json { + self.interactive().await?; + } + + let output = self.execute().await?; + if self.globals().json { + self.globals().json(&output)?; + } else { + self.finalize(&output); + } + + Ok(()) + } +} diff --git a/crates/flow-cli/src/commands/init.rs b/crates/flow-cli/src/commands/init.rs new file mode 100644 index 0000000..f067b43 --- /dev/null +++ b/crates/flow-cli/src/commands/init.rs @@ -0,0 +1,101 @@ +use std::path::PathBuf; + +use clap::Args; +use flow_core::Space; +use inquire::Text; +use miette::IntoDiagnostic; +use serde::Serialize; + +use crate::{commands::Command, common::GlobalArgs, errors::Error, extensions::PathExt}; + +#[derive(Args, Debug, Clone)] +pub struct Arguments { + #[command(flatten)] + pub global: GlobalArgs, + + pub path: Option, + + #[arg(short, long)] + pub name: Option, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct Output { + pub name: String, + pub path: PathBuf, +} + +pub struct Init { + args: Arguments, +} + +impl Command for Init { + type Args = Arguments; + type Output = Output; + + fn new(args: Self::Args) -> Self { + Self { args } + } + + fn globals(&self) -> &GlobalArgs { + &self.args.global + } + + async fn interactive(&mut self) -> miette::Result<()> { + if self.args.path.is_none() { + self.args.global.info("Entering interactive mode"); + + let path_input = Text::new("Path:") + .with_default(".") + .with_help_message("Path where the space will be initialized") + .prompt() + .into_diagnostic()?; + + self.args.path = Some(PathBuf::from(path_input)); + } + + if self.args.name.is_none() { + let mut name_prompt = Text::new("Name:").with_help_message("Name of the space"); + let test = self + .args + .path + .as_ref() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()); + if let Some(name_default) = test { + name_prompt = name_prompt.with_default(name_default); + } + + let name_input = name_prompt.prompt().into_diagnostic()?; + if !name_input.trim().is_empty() { + self.args.name = Some(name_input); + } + } + + Ok(()) + } + + async fn execute(&mut self) -> miette::Result { + let path = self + .args + .path + .take() + .ok_or_else(|| Error::MissingArgument("path".to_string()))?; + let name = self + .args + .name + .take() + .ok_or_else(|| Error::MissingArgument("name".to_string()))?; + + let _ = Space::init(&path, &name).await?; + + Ok(Output { name, path }) + } + + fn finalize(&self, output: &Self::Output) { + self.globals().success("Graph initialized successfully"); + self.globals().blank(); + self.globals().kv("Name", &output.name); + self.globals().kv("Path", output.path.normalize()); + } +} diff --git a/crates/flow-cli/src/common.rs b/crates/flow-cli/src/common.rs new file mode 100644 index 0000000..7e2507a --- /dev/null +++ b/crates/flow-cli/src/common.rs @@ -0,0 +1,132 @@ +use clap::Args; +use console::{style, Emoji, Term}; +use flow_core::Space; +use miette::{miette, IntoDiagnostic, Result}; +use std::path::PathBuf; + +// Emojis with fallbacks for terminals that don't support them +static SPARKLE: Emoji<'_, '_> = Emoji("✨ ", "* "); +static INFO: Emoji<'_, '_> = Emoji("ℹ️ ", "[i] "); +static SUCCESS: Emoji<'_, '_> = Emoji("✅ ", "[+] "); +static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "[!] "); +static ERROR: Emoji<'_, '_> = Emoji("❌ ", "[x] "); +static DEBUG: Emoji<'_, '_> = Emoji("🔍 ", "[?] "); +static ARROW: Emoji<'_, '_> = Emoji("→ ", "-> "); + +#[derive(Args, Debug, Clone)] +pub struct GlobalArgs { + /// Output in JSON format + #[arg(long, global = true)] + pub json: bool, + + /// Target specific space by name or path (overrides active space) + #[arg(long, global = true)] + pub space: Option, + + /// Detailed logging + #[arg(short, long, global = true)] + pub verbose: bool, + + /// Suppress non-error output + #[arg(short, long, global = true)] + pub quiet: bool, +} + +impl GlobalArgs { + pub async fn load_space(&self) -> Result { + let name_or_path = self + .space + .as_ref() + .ok_or_else(|| miette!("Currently only commands with --space argument is supported."))?; + + let path = PathBuf::from(name_or_path); + if !path.exists() { + return Err(flow_core::Error::NotFound(path).into()); + } + + Space::load(path).await + } + + pub fn print(&self, message: impl Into) { + if !self.quiet && !self.json { + let _ = Term::stdout().write_line(message.into().as_str()); + } + } + + pub fn success(&self, message: impl Into) { + if !self.quiet && !self.json { + let _ = Term::stdout().write_line(&format!("{}{}", SUCCESS, style(message.into()).green().bold())); + } + } + + pub fn info(&self, message: impl Into) { + if !self.quiet && !self.json { + let _ = Term::stdout().write_line(&format!("{}{}", INFO, style(message.into()).cyan())); + } + } + + pub fn warning(&self, message: impl Into) { + if !self.quiet && !self.json { + let _ = Term::stdout().write_line(&format!("{}{}", WARN, style(message.into()).yellow().bold())); + } + } + + pub fn step(&self, message: impl Into) { + if !self.quiet && !self.json { + let _ = Term::stdout().write_line(&format!("{}{}", ARROW, style(message.into()).dim())); + } + } + + pub fn verbose(&self, message: impl Into) { + if self.verbose && !self.quiet && !self.json { + let _ = Term::stdout().write_line(&format!("{}{}", DEBUG, style(message.into()).dim())); + } + } + + pub fn debug(&self, label: impl Into, value: impl Into) { + if self.verbose && !self.quiet && !self.json { + let _ = Term::stdout().write_line(&format!( + "{}{}: {}", + DEBUG, + style(label.into()).dim(), + style(value.into()).dim().italic() + )); + } + } + + pub fn error(&self, message: impl Into) { + if !self.quiet && !self.json { + let _ = Term::stderr().write_line(&format!("{}{}", ERROR, style(message.into()).red().bold())); + } + } + + pub fn heading(&self, heading: impl Into) { + if !self.quiet && !self.json { + let _ = Term::stdout().write_line(&format!("{}{}", SPARKLE, style(heading.into()).bold().underlined())); + } + } + + pub fn kv(&self, key: impl Into, value: impl Into) { + if !self.quiet && !self.json { + let _ = Term::stdout().write_line(&format!( + " {}: {}", + style(key.into()).cyan().bold(), + style(value.into()).white() + )); + } + } + + pub fn blank(&self) { + if !self.quiet && !self.json { + let _ = Term::stdout().write_line(""); + } + } + + pub fn json(&self, value: &T) -> Result<()> { + if self.json { + let json = serde_json::to_string_pretty(value).into_diagnostic()?; + let _ = Term::stdout().write_line(&json); + } + Ok(()) + } +} diff --git a/crates/flow-cli/src/errors.rs b/crates/flow-cli/src/errors.rs new file mode 100644 index 0000000..3005eeb --- /dev/null +++ b/crates/flow-cli/src/errors.rs @@ -0,0 +1,13 @@ +use miette::Diagnostic; +use thiserror::Error; + +#[derive(Debug, Error, Diagnostic)] +pub enum Error { + #[error("Filesystem error: {0}")] + #[diagnostic(code(flow::io_error), url(docsrs))] + Io(#[from] std::io::Error), + + #[error("Missing argument: {0}")] + #[diagnostic(code(flow::missing_argument), url(docsrs))] + MissingArgument(String), +} diff --git a/crates/flow-cli/src/extensions.rs b/crates/flow-cli/src/extensions.rs new file mode 100644 index 0000000..5848f20 --- /dev/null +++ b/crates/flow-cli/src/extensions.rs @@ -0,0 +1,21 @@ +use std::path::Path; + +pub trait PathExt { + fn normalize(&self) -> String; +} + +impl PathExt for Path { + fn normalize(&self) -> String { + let s = self.display().to_string(); + + #[cfg(windows)] + { + // Remove Windows extended-length path prefix + if let Some(stripped) = s.strip_prefix(r"\\?\") { + return stripped.to_string(); + } + } + + s + } +} diff --git a/crates/flow-cli/src/lib.rs b/crates/flow-cli/src/lib.rs index 6fdbe5e..efb8a25 100644 --- a/crates/flow-cli/src/lib.rs +++ b/crates/flow-cli/src/lib.rs @@ -1,10 +1,16 @@ use clap::Subcommand; -use flow_core::Space; use miette::Result; +use crate::commands::{init, Command}; + +mod commands; +mod common; +mod errors; +mod extensions; + #[derive(Subcommand)] pub enum Commands { - Test, + Init(init::Arguments), } /// Run the CLI with the given command. @@ -13,10 +19,8 @@ pub enum Commands { /// /// Returns an error if the command execution fails. pub async fn run(cmd: &Commands) -> Result<()> { - let _space = Space::load("test".to_owned()).await?; - match cmd { - Commands::Test => println!("This is a test"), + Commands::Init(args) => init::Init::new(args.clone()).run().await?, } Ok(()) From 316c788d5eafb4df8643be63a5b6f63d2bf63c16 Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Wed, 21 Jan 2026 20:21:38 +0100 Subject: [PATCH 13/19] feat(space): Initialization now creates the full path Previously when the given path did not exist a error was reported. --- crates/flow-core/src/filesystem/local.rs | 14 +++++++++++--- crates/flow-core/src/filesystem/traits.rs | 20 ++++++++++++++++++-- crates/flow-core/src/space/default.rs | 21 +++++++++------------ crates/flow-core/src/space/traits.rs | 10 +++------- 4 files changed, 41 insertions(+), 24 deletions(-) diff --git a/crates/flow-core/src/filesystem/local.rs b/crates/flow-core/src/filesystem/local.rs index c43c7ff..b4fef91 100644 --- a/crates/flow-core/src/filesystem/local.rs +++ b/crates/flow-core/src/filesystem/local.rs @@ -7,7 +7,7 @@ use std::path::Path; use miette::Result; -use tokio::fs::{create_dir, metadata, read, read_dir, read_to_string, try_exists, write}; +use tokio::fs::{create_dir, create_dir_all, metadata, read, read_dir, read_to_string, try_exists, write}; use crate::errors::Error; use crate::filesystem::traits::Filesystem; @@ -83,6 +83,14 @@ impl Filesystem for LocalFilesystem { create_dir(&path).await.map_err(|e| Error::Io(e).into()) } + /// Creates a directory using [`tokio::fs::create_dir_all`]. + /// + /// This does create parent directories. Use this only when + /// the parent directory is known to exist. + async fn create_dir_all(&self, path: impl AsRef + Send + Sync) -> Result<()> { + create_dir_all(&path).await.map_err(|e| Error::Io(e).into()) + } + /// Writes content to a file using [`tokio::fs::write`]. /// /// This operation is atomic on most platforms — the file is either @@ -101,7 +109,7 @@ impl Filesystem for LocalFilesystem { /// /// The entire file is read into memory. For large files, consider /// using streaming APIs instead. - async fn read(&self, path: impl AsRef) -> Result> { + async fn read(&self, path: impl AsRef + Send + Sync) -> Result> { let path = path.as_ref(); read(path).await.map_err(|e| Error::Io(e).into()) } @@ -110,7 +118,7 @@ impl Filesystem for LocalFilesystem { /// /// The entire file is read into memory. For large files, consider /// using streaming APIs instead. - async fn read_to_string(&self, path: impl AsRef) -> Result { + async fn read_to_string(&self, path: impl AsRef + Send + Sync) -> Result { let path = path.as_ref(); read_to_string(path).await.map_err(|e| Error::Io(e).into()) } diff --git a/crates/flow-core/src/filesystem/traits.rs b/crates/flow-core/src/filesystem/traits.rs index 604a1aa..a4c733e 100644 --- a/crates/flow-core/src/filesystem/traits.rs +++ b/crates/flow-core/src/filesystem/traits.rs @@ -85,6 +85,22 @@ pub trait Filesystem: Send + Sync { /// - Permission is denied. async fn create_dir(&self, path: impl AsRef + Send + Sync) -> Result<()>; + /// Creates a new directory at the given path. + /// + /// The parent directory must already exist. This does create parent directories. + /// + /// # Arguments + /// + /// * `path` - The path where the directory should be created. + /// + /// # Errors + /// + /// Returns an error if: + /// - The parent directory does not exist. + /// - A file or directory already exists at the path. + /// - Permission is denied. + async fn create_dir_all(&self, path: impl AsRef + Send + Sync) -> Result<()>; + /// Writes content to a file at the given path. /// /// Creates the file if it doesn't exist, or overwrites it if it does. @@ -112,7 +128,7 @@ pub trait Filesystem: Send + Sync { /// # Errors /// /// Returns an error if the file does not exist or cannot be read. - async fn read(&self, path: impl AsRef) -> Result>; + async fn read(&self, path: impl AsRef + Send + Sync) -> Result>; /// Reads the entire contents of a file into a string. /// @@ -123,5 +139,5 @@ pub trait Filesystem: Send + Sync { /// # Errors /// /// Returns an error if the file does not exist or cannot be read. - async fn read_to_string(&self, path: impl AsRef) -> Result; + async fn read_to_string(&self, path: impl AsRef + Send + Sync) -> Result; } diff --git a/crates/flow-core/src/space/default.rs b/crates/flow-core/src/space/default.rs index dda23dc..b9bb22b 100644 --- a/crates/flow-core/src/space/default.rs +++ b/crates/flow-core/src/space/default.rs @@ -160,16 +160,15 @@ impl Space for DefaultSpace { /// - The space metadata is serialized as JSON using [`serde_json`]. /// - The Loro document is exported as a binary snapshot for efficiency. /// - All filesystem operations use the injected `fs` implementation. - async fn init(fs: Self::Fs, path: impl AsRef + Send + Sync, name: impl Into) -> Result - where - Self: Sized, - { + async fn init(fs: Self::Fs, path: impl AsRef + Send + Sync, name: impl Into) -> Result { let path = path.as_ref(); let exists = fs.exists(path).await?; - ensure!(exists, Error::NotFound(path.to_path_buf())); - - let is_dir = fs.is_dir(path).await?; - ensure!(is_dir, Error::NotADirectory(path.to_path_buf())); + if exists { + let is_dir = fs.is_dir(path).await?; + ensure!(is_dir, Error::NotADirectory(path.to_path_buf())); + } else { + fs.create_dir_all(path).await?; + } let flow_dir = path.join(FLOW_DIR); let is_empty = fs.is_dir_empty(path).await?; @@ -178,6 +177,7 @@ impl Space for DefaultSpace { if has_space { return Err(Error::AlreadyExists(path.to_path_buf()).into()); } + return Err(Error::DirectoryNotEmpty(path.to_path_buf()).into()); } @@ -219,10 +219,7 @@ impl Space for DefaultSpace { /// # Unimplemented /// /// This method is not yet implemented and will panic if called. - async fn load(fs: Self::Fs, locator: Locator) -> Result - where - Self: Sized, - { + async fn load(fs: Self::Fs, locator: Locator) -> Result { let path = match locator { Locator::Name(_name) => todo!("Look up the path through the name of the space"), Locator::Path(path) => path, diff --git a/crates/flow-core/src/space/traits.rs b/crates/flow-core/src/space/traits.rs index 9cf3a9f..bdc3dc7 100644 --- a/crates/flow-core/src/space/traits.rs +++ b/crates/flow-core/src/space/traits.rs @@ -55,7 +55,7 @@ use crate::{filesystem::Filesystem, space::locator::Locator}; /// } /// } /// ``` -pub trait Space: Send + Sync { +pub trait Space: Sized + Send + Sync { /// The filesystem implementation used by this space. /// /// This associated type allows space implementations to be generic @@ -93,9 +93,7 @@ pub trait Space: Send + Sync { /// - The path already contains a space. /// - The directory cannot be created. /// - The configuration file cannot be written. - async fn init(fs: Self::Fs, path: impl AsRef + Send + Sync, name: impl Into) -> Result - where - Self: Sized; + async fn init(fs: Self::Fs, path: impl AsRef + Send + Sync, name: impl Into) -> Result; /// Load an existing space from the given locator. /// @@ -113,7 +111,5 @@ pub trait Space: Send + Sync { /// - The space cannot be found. /// - The space configuration is invalid or corrupted. /// - The filesystem cannot be read. - async fn load(fs: Self::Fs, locator: Locator) -> Result - where - Self: Sized; + async fn load(fs: Self::Fs, locator: Locator) -> Result; } From c27384b60c3051b91442309905d786bc5577147a Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Wed, 21 Jan 2026 20:27:56 +0100 Subject: [PATCH 14/19] feat(config): Add XDG compliant configuration module Currently works with two files: - ~/.config/flow/config.json > Holds all user defined global settings - ~/.config/flow/spaces.json > Holds registered spaces and currently active one via `flow open` CLI --- Cargo.lock | 9 +- crates/flow-core/Cargo.toml | 1 + crates/flow-core/src/config.rs | 366 +++++++++++++++++++++++++ crates/flow-core/src/config/default.rs | 228 +++++++++++++++ crates/flow-core/src/config/traits.rs | 149 ++++++++++ crates/flow-core/src/config/types.rs | 112 ++++++++ crates/flow-core/src/errors.rs | 62 +++++ crates/flow-core/src/lib.rs | 2 + crates/flow-core/src/space.rs | 45 +++ crates/flow-core/src/space/default.rs | 8 + crates/flow-core/src/space/locator.rs | 73 ++--- crates/flow-core/src/space/traits.rs | 27 ++ 12 files changed, 1024 insertions(+), 58 deletions(-) create mode 100644 crates/flow-core/src/config.rs create mode 100644 crates/flow-core/src/config/default.rs create mode 100644 crates/flow-core/src/config/traits.rs create mode 100644 crates/flow-core/src/config/types.rs diff --git a/Cargo.lock b/Cargo.lock index c231a2e..f6bb64a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -333,6 +333,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "cross-xdg" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd89fbf9e0d3bf9c91e17c09fd875857f9d71c3cbe29ae2ab253edd080b17ce5" + [[package]] name = "crossterm" version = "0.29.0" @@ -605,6 +611,7 @@ dependencies = [ name = "flow-core" version = "0.1.0" dependencies = [ + "cross-xdg", "loro", "miette", "serde", @@ -1790,7 +1797,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/flow-core/Cargo.toml b/crates/flow-core/Cargo.toml index 957a8a3..ce44a37 100644 --- a/crates/flow-core/Cargo.toml +++ b/crates/flow-core/Cargo.toml @@ -19,6 +19,7 @@ thiserror = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } loro = "1.10.3" +cross-xdg = "2.1" [lints] workspace = true diff --git a/crates/flow-core/src/config.rs b/crates/flow-core/src/config.rs new file mode 100644 index 0000000..5d07f3a --- /dev/null +++ b/crates/flow-core/src/config.rs @@ -0,0 +1,366 @@ +//! Configuration management for Flow. +//! +//! This module provides the configuration system for Flow, managing both +//! user settings and the registry of known spaces. +//! +//! # Overview +//! +//! Flow stores its configuration in `~/.config/flow/` with two files: +//! +//! - `config.json` - User preferences and settings +//! - `spaces.json` - Registered spaces and the active space +//! +//! The [`Config`] struct provides the main interface for working with +//! configuration. It supports: +//! +//! - Registering and unregistering spaces +//! - Setting and clearing the active space +//! - Looking up spaces by name or path +//! - Accessing user settings +//! +//! # Examples +//! +//! ## Loading configuration +//! +//! ```no_run +//! use flow_core::Config; +//! +//! # async fn example() -> miette::Result<()> { +//! let config = Config::load().await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Registering a space +//! +//! ```no_run +//! use std::path::Path; +//! use flow_core::{Config, Space}; +//! +//! # async fn example() -> miette::Result<()> { +//! let space = Space::init(Path::new("./notes"), "personal").await?; +//! let mut config = Config::load().await?; +//! config.register(&space).await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Setting the active space +//! +//! ```no_run +//! use flow_core::Config; +//! +//! # async fn example() -> miette::Result<()> { +//! let mut config = Config::load().await?; +//! config.set_active("personal").await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! # Architecture +//! +//! The public [`Config`] struct is a thin wrapper around an internal +//! implementation (`DefaultConfig`). This design allows us to: +//! +//! - Keep the public API simple and stable +//! - Inject different filesystem implementations for testing +//! - Potentially support different configuration backends in the future + +use miette::Result; + +use crate::filesystem::LocalFilesystem; +use crate::space::{Locator, Space}; + +use self::default::DefaultConfig; +use self::traits::Config as _; + +mod default; +mod traits; +mod types; + +pub use self::types::{RegisteredSpace, Settings}; + +/// Flow configuration manager. +/// +/// `Config` is the main entry point for managing Flow's configuration. +/// It provides methods to register spaces, set the active space, and +/// access user settings. +/// +/// # Examples +/// +/// ```no_run +/// use std::path::Path; +/// use flow_core::{Config, Space}; +/// +/// # async fn example() -> miette::Result<()> { +/// // Load configuration (creates default if none exists) +/// let mut config = Config::load().await?; +/// +/// // Initialize and register a space +/// let space = Space::init(Path::new("./work-notes"), "work").await?; +/// config.register(&space).await?; +/// +/// // Set it as active +/// config.set_active("work").await?; +/// +/// // Look up a space +/// if let Some(registered) = config.find("work") { +/// println!("Found space at: {}", registered.path.display()); +/// } +/// # Ok(()) +/// # } +/// ``` +/// +/// # Thread Safety +/// +/// `Config` is `Send` and `Sync`, making it safe to share across threads +/// and use in async contexts. +pub struct Config { + /// The underlying configuration implementation. + inner: DefaultConfig, +} + +impl Config { + /// Loads the configuration from disk. + /// + /// If the configuration files don't exist, they will be created with + /// default values in `~/.config/flow/`. + /// + /// # Errors + /// + /// Returns an error if: + /// - The configuration directory cannot be created + /// - The configuration files cannot be read or written + /// - The configuration files contain invalid JSON + /// + /// # Examples + /// + /// ```no_run + /// use flow_core::Config; + /// + /// # async fn example() -> miette::Result<()> { + /// let config = Config::load().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn load() -> Result { + let fs = LocalFilesystem; + let inner = DefaultConfig::load(fs).await?; + Ok(Self { inner }) + } + + /// Registers a space in the configuration. + /// + /// The space will be added to the list of known spaces, allowing it + /// to be opened by name in the future. + /// + /// # Arguments + /// + /// * `space` - A reference to the space to register. + /// + /// # Errors + /// + /// Returns an error if: + /// - A space with the same name is already registered + /// - A space at the same path is already registered + /// - The configuration cannot be saved + /// + /// # Examples + /// + /// ```no_run + /// use std::path::Path; + /// use flow_core::{Config, Space}; + /// + /// # async fn example() -> miette::Result<()> { + /// let space = Space::init(Path::new("./notes"), "personal").await?; + /// let mut config = Config::load().await?; + /// config.register(&space).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn register(&mut self, space: &Space) -> Result<()> { + self.inner.register(space).await + } + + /// Unregisters a space from the configuration. + /// + /// The space will be removed from the list of known spaces. If the + /// space was the active space, the active space will be cleared. + /// + /// # Arguments + /// + /// * `locator` - Identifies the space to unregister, either by name or path. + /// + /// # Errors + /// + /// Returns an error if: + /// - The space is not registered + /// - The configuration cannot be saved + /// + /// # Examples + /// + /// ```no_run + /// use flow_core::Config; + /// + /// # async fn example() -> miette::Result<()> { + /// let mut config = Config::load().await?; + /// config.unregister("personal").await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn unregister(&mut self, locator: impl Into + Send) -> Result<()> { + self.inner.unregister(locator).await + } + + /// Sets the active space. + /// + /// The active space is the default space used when no space is + /// explicitly specified in commands. + /// + /// # Arguments + /// + /// * `locator` - Identifies the space to set as active, either by name or path. + /// + /// # Errors + /// + /// Returns an error if: + /// - The space is not registered + /// - The configuration cannot be saved + /// + /// # Examples + /// + /// ```no_run + /// use flow_core::Config; + /// + /// # async fn example() -> miette::Result<()> { + /// let mut config = Config::load().await?; + /// config.set_active("personal").await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn set_active(&mut self, locator: impl Into + Send) -> Result<()> { + self.inner.set_active(locator).await + } + + /// Clears the active space. + /// + /// After calling this, no space will be active until one is explicitly + /// set again. + /// + /// # Errors + /// + /// Returns an error if the configuration cannot be saved. + /// + /// # Examples + /// + /// ```no_run + /// use flow_core::Config; + /// + /// # async fn example() -> miette::Result<()> { + /// let mut config = Config::load().await?; + /// config.clear_active().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn clear_active(&mut self) -> Result<()> { + self.inner.clear_active().await + } + + /// Returns the currently active space, if any. + /// + /// # Returns + /// + /// A reference to the active space, or `None` if no space is active. + /// + /// # Examples + /// + /// ```no_run + /// use flow_core::Config; + /// + /// # async fn example() -> miette::Result<()> { + /// let config = Config::load().await?; + /// if let Some(space) = config.active() { + /// println!("Active space: {}", space.name); + /// } + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn active(&self) -> Option<&RegisteredSpace> { + self.inner.active() + } + + /// Finds a registered space by name or path. + /// + /// # Arguments + /// + /// * `locator` - Identifies the space to find, either by name or path. + /// + /// # Returns + /// + /// A reference to the registered space, or `None` if not found. + /// + /// # Examples + /// + /// ```no_run + /// use flow_core::Config; + /// + /// # async fn example() -> miette::Result<()> { + /// let config = Config::load().await?; + /// if let Some(space) = config.find("personal") { + /// println!("Found at: {}", space.path.display()); + /// } + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn find(&self, locator: impl Into) -> Option<&RegisteredSpace> { + self.inner.find(locator) + } + + /// Returns all registered spaces. + /// + /// # Returns + /// + /// A slice containing all registered spaces. + /// + /// # Examples + /// + /// ```no_run + /// use flow_core::Config; + /// + /// # async fn example() -> miette::Result<()> { + /// let config = Config::load().await?; + /// for space in config.spaces() { + /// println!("{}: {}", space.name, space.path.display()); + /// } + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn spaces(&self) -> &[RegisteredSpace] { + self.inner.spaces() + } + + /// Returns the current user settings. + /// + /// # Returns + /// + /// A reference to the settings. + /// + /// # Examples + /// + /// ```no_run + /// use flow_core::Config; + /// + /// # async fn example() -> miette::Result<()> { + /// let config = Config::load().await?; + /// println!("Config version: {}", config.settings().version); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn settings(&self) -> &Settings { + self.inner.settings() + } +} diff --git a/crates/flow-core/src/config/default.rs b/crates/flow-core/src/config/default.rs new file mode 100644 index 0000000..ff859dd --- /dev/null +++ b/crates/flow-core/src/config/default.rs @@ -0,0 +1,228 @@ +//! Default implementation of the [`Config`] trait. +//! +//! This module provides [`DefaultConfig`], the standard implementation of +//! the config trait used in production. It is generic over the filesystem +//! implementation to allow for dependency injection during testing. +//! +//! # Overview +//! +//! The [`DefaultConfig`] struct manages Flow's configuration, including: +//! +//! - User settings stored in `~/.config/flow/config.json` +//! - Registered spaces stored in `~/.config/flow/spaces.json` +//! +//! # Directory Structure +//! +//! Configuration is stored in the XDG config directory: +//! +//! ```text +//! ~/.config/flow/ +//! ├── config.json # User settings +//! └── spaces.json # Registered spaces +//! ``` + +use std::path::PathBuf; + +use cross_xdg::BaseDirs; +use miette::{IntoDiagnostic, Result}; + +use crate::errors::Error; +use crate::filesystem::Filesystem; +use crate::space::Locator; + +use super::traits::Config; +use super::types::{RegisteredSpace, Settings, Spaces}; +use crate::space::Space; + +/// The directory name for Flow configuration under the XDG config home. +const CONFIG_DIR: &str = "flow"; + +/// The filename for user settings. +const SETTINGS_FILE: &str = "config.json"; + +/// The filename for registered spaces. +const SPACES_FILE: &str = "spaces.json"; + +/// The default implementation of Flow configuration. +/// +/// `DefaultConfig` is generic over the filesystem implementation, allowing +/// different storage backends to be used. In production, this is typically +/// [`LocalFilesystem`](crate::filesystem::LocalFilesystem), while tests +/// can inject mock implementations. +/// +/// # Type Parameters +/// +/// * `F` - The filesystem implementation to use for all I/O operations. +/// Must implement [`Filesystem`] and be thread-safe (`Send + Sync`). +pub struct DefaultConfig { + /// The filesystem backend for all I/O operations. + fs: F, + + /// The path to the configuration directory. + config_dir: PathBuf, + + /// User settings. + settings: Settings, + + /// Registered spaces. + spaces: Spaces, +} + +impl DefaultConfig { + /// Returns the path to the configuration directory. + /// + /// Uses `cross-xdg` to determine the XDG config home, then appends + /// the Flow-specific directory name. + fn config_dir() -> Result { + let base_dirs = BaseDirs::new().into_diagnostic()?; + Ok(base_dirs.config_home().join(CONFIG_DIR)) + } + + /// Persists the settings to disk. + #[allow(dead_code)] + async fn save_settings(&self) -> Result<()> { + let path = self.config_dir.join(SETTINGS_FILE); + let json = serde_json::to_string_pretty(&self.settings).into_diagnostic()?; + self.fs.write(&path, json.as_bytes()).await + } + + /// Persists the spaces to disk. + async fn save_spaces(&self) -> Result<()> { + let path = self.config_dir.join(SPACES_FILE); + let json = serde_json::to_string_pretty(&self.spaces).into_diagnostic()?; + self.fs.write(&path, json.as_bytes()).await + } + + /// Finds a space by locator and returns its index. + fn find_index(&self, locator: &Locator) -> Option { + match locator { + Locator::Name(name) => self.spaces.spaces.iter().position(|s| &s.name == name), + Locator::Path(path) => self.spaces.spaces.iter().position(|s| &s.path == path), + } + } +} + +impl Config for DefaultConfig { + type Fs = F; + + async fn load(fs: Self::Fs) -> Result + where + Self: Sized, + { + let config_dir = Self::config_dir()?; + + if !fs.exists(&config_dir).await? { + fs.create_dir_all(&config_dir).await?; + } + + let settings_path = config_dir.join(SETTINGS_FILE); + let settings = if fs.exists(&settings_path).await? { + let json = fs.read_to_string(&settings_path).await?; + serde_json::from_str(&json).into_diagnostic()? + } else { + let settings = Settings { + version: env!("CARGO_PKG_VERSION").to_string(), + }; + let json = serde_json::to_string_pretty(&settings).into_diagnostic()?; + fs.write(&settings_path, json.as_bytes()).await?; + settings + }; + + let spaces_path = config_dir.join(SPACES_FILE); + let spaces = if fs.exists(&spaces_path).await? { + let json = fs.read_to_string(&spaces_path).await?; + serde_json::from_str(&json).into_diagnostic()? + } else { + let spaces = Spaces::default(); + let json = serde_json::to_string_pretty(&spaces).into_diagnostic()?; + fs.write(&spaces_path, json.as_bytes()).await?; + spaces + }; + + Ok(Self { + fs, + config_dir, + settings, + spaces, + }) + } + + async fn register(&mut self, space: &Space) -> Result<()> { + let name = space.name(); + let path = space.path(); + + // Check if already registered by name + if self.spaces.spaces.iter().any(|s| s.name == name) { + return Err(Error::SpaceAlreadyRegistered(name.to_string()).into()); + } + + // Check if already registered by path + if self.spaces.spaces.iter().any(|s| s.path == path) { + return Err(Error::SpacePathAlreadyRegistered(path.to_path_buf()).into()); + } + + self.spaces.spaces.push(RegisteredSpace { + name: name.to_string(), + path: path.to_path_buf(), + }); + + self.save_spaces().await + } + + async fn unregister(&mut self, locator: impl Into + Send) -> Result<()> { + let locator = locator.into(); + + let index = self + .find_index(&locator) + .ok_or_else(|| Error::SpaceNotRegistered(locator.clone()))?; + + let removed = self.spaces.spaces.remove(index); + + // Clear active if it was the removed space + if self.spaces.active.as_ref() == Some(&removed.name) { + self.spaces.active = None; + } + + self.save_spaces().await + } + + async fn set_active(&mut self, locator: impl Into + Send) -> Result<()> { + let locator = locator.into(); + + let space = self + .find(locator.clone()) + .ok_or(Error::SpaceNotRegistered(locator))?; + + self.spaces.active = Some(space.name.clone()); + + self.save_spaces().await + } + + async fn clear_active(&mut self) -> Result<()> { + self.spaces.active = None; + self.save_spaces().await + } + + fn active(&self) -> Option<&RegisteredSpace> { + self.spaces + .active + .as_ref() + .and_then(|name| self.spaces.spaces.iter().find(|s| &s.name == name)) + } + + fn find(&self, locator: impl Into) -> Option<&RegisteredSpace> { + let locator = locator.into(); + match locator { + Locator::Name(name) => self.spaces.spaces.iter().find(|s| s.name == name), + Locator::Path(path) => self.spaces.spaces.iter().find(|s| s.path == path), + } + } + + fn spaces(&self) -> &[RegisteredSpace] { + &self.spaces.spaces + } + + fn settings(&self) -> &Settings { + &self.settings + } +} diff --git a/crates/flow-core/src/config/traits.rs b/crates/flow-core/src/config/traits.rs new file mode 100644 index 0000000..efd57e8 --- /dev/null +++ b/crates/flow-core/src/config/traits.rs @@ -0,0 +1,149 @@ +//! Configuration trait definition. +//! +//! This module defines the [`Config`] trait, which provides the interface +//! for managing Flow's configuration, including registered spaces and +//! user settings. + +use miette::Result; + +use crate::filesystem::Filesystem; +use crate::space::Locator; + +use super::types::{RegisteredSpace, Settings}; +use crate::space::Space; + +/// Configuration management for Flow. +/// +/// This trait defines the interface for loading, saving, and managing +/// Flow's configuration. It handles both user settings and the registry +/// of known spaces. +/// +/// # Implementors +/// +/// - [`DefaultConfig`](super::default::DefaultConfig) - The standard implementation +/// using the filesystem abstraction. +/// +/// # Examples +/// +/// ```ignore +/// use flow_core::config::{Config, DefaultConfig}; +/// use flow_core::filesystem::LocalFilesystem; +/// use flow_core::Space; +/// +/// let fs = LocalFilesystem; +/// let mut config = DefaultConfig::load(fs).await?; +/// +/// // Initialize and register a space +/// let space = Space::init("./notes", "personal").await?; +/// config.register(&space).await?; +/// +/// // Set it as active +/// config.set_active("personal").await?; +/// ``` +pub trait Config: Sized + Send + Sync { + /// The filesystem implementation used for persistence. + type Fs: Filesystem; + + /// Loads the configuration from disk. + /// + /// If the configuration files don't exist, they will be created with + /// default values. + /// + /// # Errors + /// + /// Returns an error if: + /// - The configuration directory cannot be created + /// - The configuration files cannot be read or written + /// - The configuration files contain invalid JSON + async fn load(fs: Self::Fs) -> Result; + + /// Registers a space in the configuration. + /// + /// The space will be added to the list of known spaces, allowing it + /// to be opened by name in the future. + /// + /// # Arguments + /// + /// * `space` - A reference to the space to register. + /// + /// # Errors + /// + /// Returns an error if: + /// - A space with the same name is already registered + /// - The configuration cannot be saved + async fn register(&mut self, space: &Space) -> Result<()>; + + /// Unregisters a space from the configuration. + /// + /// The space will be removed from the list of known spaces. If the + /// space was the active space, the active space will be cleared. + /// + /// # Arguments + /// + /// * `locator` - Identifies the space to unregister, either by name or path. + /// + /// # Errors + /// + /// Returns an error if: + /// - The space is not registered + /// - The configuration cannot be saved + async fn unregister(&mut self, locator: impl Into + Send) -> Result<()>; + + /// Sets the active space. + /// + /// The active space is the default space used when no space is + /// explicitly specified in commands. + /// + /// # Arguments + /// + /// * `locator` - Identifies the space to set as active, either by name or path. + /// + /// # Errors + /// + /// Returns an error if: + /// - The space is not registered + /// - The configuration cannot be saved + async fn set_active(&mut self, locator: impl Into + Send) -> Result<()>; + + /// Clears the active space. + /// + /// After calling this, no space will be active until one is explicitly + /// set again. + /// + /// # Errors + /// + /// Returns an error if the configuration cannot be saved. + async fn clear_active(&mut self) -> Result<()>; + + /// Returns the currently active space, if any. + /// + /// # Returns + /// + /// A reference to the active space, or `None` if no space is active. + fn active(&self) -> Option<&RegisteredSpace>; + + /// Finds a registered space by name or path. + /// + /// # Arguments + /// + /// * `locator` - Identifies the space to find, either by name or path. + /// + /// # Returns + /// + /// A reference to the registered space, or `None` if not found. + fn find(&self, locator: impl Into) -> Option<&RegisteredSpace>; + + /// Returns all registered spaces. + /// + /// # Returns + /// + /// A slice containing all registered spaces. + fn spaces(&self) -> &[RegisteredSpace]; + + /// Returns the current user settings. + /// + /// # Returns + /// + /// A reference to the settings. + fn settings(&self) -> &Settings; +} diff --git a/crates/flow-core/src/config/types.rs b/crates/flow-core/src/config/types.rs new file mode 100644 index 0000000..75d34a4 --- /dev/null +++ b/crates/flow-core/src/config/types.rs @@ -0,0 +1,112 @@ +//! Configuration data types. +//! +//! This module provides the data structures for Flow's configuration system, +//! including user settings and registered spaces. +//! +//! # Overview +//! +//! Flow stores its configuration in `~/.config/flow/` with two files: +//! +//! - `config.json` - User preferences and settings +//! - `spaces.json` - Registered spaces and the active space +//! +//! # Serialization +//! +//! All types are serialized to JSON using [`serde`]. The format is designed +//! to be human-readable and forward-compatible. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// User configuration settings. +/// +/// This struct holds user preferences that affect Flow's behavior. +/// It is stored in `~/.config/flow/config.json`. +/// +/// # Serialization Format +/// +/// ```json +/// { +/// "version": "0.1.0" +/// } +/// ``` +/// +/// # Future Extensions +/// +/// This struct is intentionally minimal. Future versions may add fields for: +/// - Editor preferences +/// - Default space settings +/// - UI customization +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Settings { + /// The Flow version that created this configuration. + /// + /// Used for future migrations if the configuration format changes. + pub version: String, +} + +/// A registered space entry. +/// +/// Each registered space has a name and a filesystem path. The name +/// is used for quick access via commands like `flow open `. +/// +/// # Serialization Format +/// +/// ```json +/// { +/// "name": "personal", +/// "path": "/home/user/spaces/personal" +/// } +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RegisteredSpace { + /// The human-readable name of the space. + /// + /// This name is unique within the registry and is used to + /// identify the space in commands. + pub name: String, + + /// The filesystem path to the space directory. + /// + /// This is an absolute path to the directory containing the + /// `.flow/` subdirectory. + pub path: PathBuf, +} + +/// Registry of known spaces and the active space. +/// +/// This struct maintains the list of all registered spaces and tracks +/// which space is currently active. It is stored in `~/.config/flow/spaces.json`. +/// +/// # Serialization Format +/// +/// ```json +/// { +/// "active": "personal", +/// "spaces": [ +/// { +/// "name": "personal", +/// "path": "/home/user/spaces/personal" +/// }, +/// { +/// "name": "work", +/// "path": "/home/user/spaces/work" +/// } +/// ] +/// } +/// ``` +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Spaces { + /// The name of the currently active space. + /// + /// This is `None` if no space has been activated yet. + /// When set, it must match the name of a space in the `spaces` list. + pub active: Option, + + /// The list of registered spaces. + /// + /// Each space has a unique name that can be used to quickly + /// open or reference it. + pub spaces: Vec, +} diff --git a/crates/flow-core/src/errors.rs b/crates/flow-core/src/errors.rs index 81b947d..d659996 100644 --- a/crates/flow-core/src/errors.rs +++ b/crates/flow-core/src/errors.rs @@ -36,6 +36,8 @@ use std::path::PathBuf; use miette::Diagnostic; use thiserror::Error; +use crate::space::Locator; + /// Errors that can occur when working with spaces. /// /// This enum covers all error conditions that may arise during space @@ -174,4 +176,64 @@ pub enum Error { help("Initialize a space in an empty directory, or use a different path") )] DirectoryNotEmpty(PathBuf), + + /// A space with the given name is already registered. + /// + /// This error occurs when attempting to register a space with a name + /// that is already in use by another registered space. + /// + /// # Error Code + /// + /// `flow::space_already_registered` + /// + /// # Fields + /// + /// * `0` - The name that is already registered. + #[error("A space with the name '{0}' is already registered")] + #[diagnostic( + code(flow::space_already_registered), + url(docsrs), + help("Use a different name, or unregister the existing space first") + )] + SpaceAlreadyRegistered(String), + + /// A space at the given path is already registered. + /// + /// This error occurs when attempting to register a space at a path + /// that is already registered under a different name. + /// + /// # Error Code + /// + /// `flow::space_path_already_registered` + /// + /// # Fields + /// + /// * `0` - The path that is already registered. + #[error("A space at path '{0}' is already registered")] + #[diagnostic( + code(flow::space_path_already_registered), + url(docsrs), + help("This space is already registered under a different name") + )] + SpacePathAlreadyRegistered(PathBuf), + + /// The specified space is not registered. + /// + /// This error occurs when attempting to operate on a space that + /// has not been registered in the configuration. + /// + /// # Error Code + /// + /// `flow::space_not_registered` + /// + /// # Fields + /// + /// * `0` - The locator used to find the space. + #[error("Space not registered: {0}")] + #[diagnostic( + code(flow::space_not_registered), + url(docsrs), + help("Register the space first with `flow register`") + )] + SpaceNotRegistered(Locator), } diff --git a/crates/flow-core/src/lib.rs b/crates/flow-core/src/lib.rs index c662ecf..c1e25ad 100644 --- a/crates/flow-core/src/lib.rs +++ b/crates/flow-core/src/lib.rs @@ -91,9 +91,11 @@ mod filesystem; +mod config; mod errors; mod space; +pub use self::config::Config; pub use self::errors::Error; pub use self::space::Locator; pub use self::space::Space; diff --git a/crates/flow-core/src/space.rs b/crates/flow-core/src/space.rs index 1ac89f9..c78c8a1 100644 --- a/crates/flow-core/src/space.rs +++ b/crates/flow-core/src/space.rs @@ -186,6 +186,51 @@ impl Space { Ok(Self { inner }) } + + /// Returns the name of the space. + /// + /// The name is a human-readable identifier for the space, set during + /// initialization. It is used for display purposes and can be used + /// to look up the space in the registry. + /// + /// # Examples + /// + /// ```no_run + /// use std::path::Path; + /// use flow_core::Space; + /// + /// # async fn example() -> miette::Result<()> { + /// let space = Space::init(Path::new("./my-notes"), "personal").await?; + /// assert_eq!(space.name(), "personal"); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn name(&self) -> &str { + self.inner.name() + } + + /// Returns the filesystem path to the space directory. + /// + /// This is the root directory containing the `.flow/` subdirectory + /// and all space content. + /// + /// # Examples + /// + /// ```no_run + /// use std::path::Path; + /// use flow_core::Space; + /// + /// # async fn example() -> miette::Result<()> { + /// let space = Space::init(Path::new("./my-notes"), "personal").await?; + /// assert_eq!(space.path(), Path::new("./my-notes")); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn path(&self) -> &Path { + self.inner.path() + } } #[cfg(test)] diff --git a/crates/flow-core/src/space/default.rs b/crates/flow-core/src/space/default.rs index b9bb22b..6b50121 100644 --- a/crates/flow-core/src/space/default.rs +++ b/crates/flow-core/src/space/default.rs @@ -241,6 +241,14 @@ impl Space for DefaultSpace { doc, }) } + + fn name(&self) -> &str { + &self.metadata.name + } + + fn path(&self) -> &Path { + &self.path + } } #[cfg(test)] diff --git a/crates/flow-core/src/space/locator.rs b/crates/flow-core/src/space/locator.rs index a03a1c6..66a070c 100644 --- a/crates/flow-core/src/space/locator.rs +++ b/crates/flow-core/src/space/locator.rs @@ -20,6 +20,7 @@ //! - `String` and `&str` are interpreted as space names. //! - `PathBuf` and `&Path` are interpreted as filesystem paths. +use std::fmt; use std::path::{Path, PathBuf}; /// A way to locate a [`Space`](super::Space). @@ -75,82 +76,40 @@ pub enum Locator { } impl From for Locator { - /// Converts a [`String`] into a [`Locator::Name`]. - /// - /// # Arguments - /// - /// * `s` - The space name to convert. - /// - /// # Examples - /// - /// ``` - /// use flow_core::Locator; - /// - /// let locator: Locator = String::from("personal").into(); - /// assert_eq!(locator, Locator::Name("personal".to_string())); - /// ``` fn from(s: String) -> Self { Self::Name(s) } } +impl From<&String> for Locator { + fn from(s: &String) -> Self { + Self::Name(s.clone()) + } +} + impl From<&str> for Locator { - /// Converts a string slice into a [`Locator::Name`]. - /// - /// # Arguments - /// - /// * `s` - The space name to convert. - /// - /// # Examples - /// - /// ``` - /// use flow_core::Locator; - /// - /// let locator: Locator = "work".into(); - /// assert_eq!(locator, Locator::Name("work".to_string())); - /// ``` fn from(s: &str) -> Self { Self::Name(s.to_owned()) } } impl From for Locator { - /// Converts a [`PathBuf`] into a [`Locator::Path`]. - /// - /// # Arguments - /// - /// * `path` - The filesystem path to convert. - /// - /// # Examples - /// - /// ``` - /// use std::path::PathBuf; - /// use flow_core::Locator; - /// - /// let locator: Locator = PathBuf::from("/home/user/notes").into(); - /// assert_eq!(locator, Locator::Path(PathBuf::from("/home/user/notes"))); - /// ``` fn from(path: PathBuf) -> Self { Self::Path(path) } } impl From<&Path> for Locator { - /// Converts a [`Path`] reference into a [`Locator::Path`]. - /// - /// # Arguments - /// - /// * `path` - The filesystem path to convert. - /// - /// # Examples - /// - /// ``` - /// use std::path::Path; - /// use flow_core::Locator; - /// - /// let locator: Locator = Path::new("/home/user/notes").into(); - /// ``` fn from(path: &Path) -> Self { Self::Path(path.to_path_buf()) } } + +impl fmt::Display for Locator { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Name(name) => write!(f, "{name}"), + Self::Path(path) => write!(f, "{}", path.display()), + } + } +} diff --git a/crates/flow-core/src/space/traits.rs b/crates/flow-core/src/space/traits.rs index bdc3dc7..1245d9d 100644 --- a/crates/flow-core/src/space/traits.rs +++ b/crates/flow-core/src/space/traits.rs @@ -112,4 +112,31 @@ pub trait Space: Sized + Send + Sync { /// - The space configuration is invalid or corrupted. /// - The filesystem cannot be read. async fn load(fs: Self::Fs, locator: Locator) -> Result; + + /// Returns the name of the space. + /// + /// The name is a human-readable identifier for the space, set during + /// initialization. It is used for display purposes and can be used + /// to look up the space in the registry. + /// + /// # Examples + /// + /// ```ignore + /// let space = DefaultSpace::init(fs, "./my-space", "personal").await?; + /// assert_eq!(space.name(), "personal"); + /// ``` + fn name(&self) -> &str; + + /// Returns the filesystem path to the space directory. + /// + /// This is the root directory containing the `.flow/` subdirectory + /// and all space content. + /// + /// # Examples + /// + /// ```ignore + /// let space = DefaultSpace::init(fs, "./my-space", "personal").await?; + /// assert_eq!(space.path(), Path::new("./my-space")); + /// ``` + fn path(&self) -> &Path; } From 9a1657211b0d8747a9f38828a391cb72285b2d9c Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Wed, 21 Jan 2026 20:29:37 +0100 Subject: [PATCH 15/19] feat(cli): Add space loading via `--space` flag from configuration Also includes some minor refactoring to make the code more readable. Common arguments like Output and Space no longer control the logging behaviour based on the given arguments. --- crates/flow-cli/src/commands.rs | 14 ++- crates/flow-cli/src/commands/init.rs | 41 +++++-- crates/flow-cli/src/common.rs | 155 +++++++++------------------ crates/flow-cli/src/errors.rs | 8 ++ crates/flow-cli/src/lib.rs | 1 + crates/flow-cli/src/printer.rs | 114 ++++++++++++++++++++ crates/flow/src/main.rs | 4 - 7 files changed, 214 insertions(+), 123 deletions(-) create mode 100644 crates/flow-cli/src/printer.rs diff --git a/crates/flow-cli/src/commands.rs b/crates/flow-cli/src/commands.rs index 1e2ff7e..74228a5 100644 --- a/crates/flow-cli/src/commands.rs +++ b/crates/flow-cli/src/commands.rs @@ -1,6 +1,6 @@ use miette::Result; -use crate::common::GlobalArgs; +use crate::{common::OutputArgs, printer::Printer}; pub mod init; @@ -10,7 +10,11 @@ pub trait Command: Sized { fn new(args: Self::Args) -> Self; - fn globals(&self) -> &GlobalArgs; + fn output_args(&self) -> &OutputArgs; + + fn printer(&self) -> Printer { + self.output_args().printer() + } async fn interactive(&mut self) -> Result<()>; @@ -19,13 +23,13 @@ pub trait Command: Sized { fn finalize(&self, output: &Self::Output); async fn run(&mut self) -> Result<()> { - if !self.globals().json { + if !self.output_args().json { self.interactive().await?; } let output = self.execute().await?; - if self.globals().json { - self.globals().json(&output)?; + if self.output_args().json { + self.printer().json(&output)?; } else { self.finalize(&output); } diff --git a/crates/flow-cli/src/commands/init.rs b/crates/flow-cli/src/commands/init.rs index f067b43..e966ad3 100644 --- a/crates/flow-cli/src/commands/init.rs +++ b/crates/flow-cli/src/commands/init.rs @@ -1,22 +1,28 @@ use std::path::PathBuf; use clap::Args; -use flow_core::Space; +use flow_core::{Config, Space}; use inquire::Text; use miette::IntoDiagnostic; use serde::Serialize; -use crate::{commands::Command, common::GlobalArgs, errors::Error, extensions::PathExt}; +use crate::{commands::Command, common::OutputArgs, errors::Error, extensions::PathExt}; #[derive(Args, Debug, Clone)] pub struct Arguments { #[command(flatten)] - pub global: GlobalArgs, + pub output: OutputArgs, + /// Path to initialize the space at pub path: Option, + /// Name of the space (defaults to the directory name) #[arg(short, long)] pub name: Option, + + /// Flag, to not register the space + #[arg(long)] + pub no_register: bool, } #[derive(Debug, Clone, Default, Serialize)] @@ -37,13 +43,13 @@ impl Command for Init { Self { args } } - fn globals(&self) -> &GlobalArgs { - &self.args.global + fn output_args(&self) -> &OutputArgs { + &self.args.output } async fn interactive(&mut self) -> miette::Result<()> { if self.args.path.is_none() { - self.args.global.info("Entering interactive mode"); + self.printer().info("Entering interactive mode"); let path_input = Text::new("Path:") .with_default(".") @@ -81,21 +87,34 @@ impl Command for Init { .path .take() .ok_or_else(|| Error::MissingArgument("path".to_string()))?; + + let path_name = path.file_name().and_then(|n| n.to_str()); let name = self .args .name .take() + .or_else(|| path_name.map(String::from)) .ok_or_else(|| Error::MissingArgument("name".to_string()))?; - let _ = Space::init(&path, &name).await?; + let space = Space::init(&path, &name).await?; + if !self.args.no_register { + let mut config = Config::load().await?; + + config.register(&space).await?; + if config.active().is_none() { + config.set_active(space.name()).await?; + } + } Ok(Output { name, path }) } fn finalize(&self, output: &Self::Output) { - self.globals().success("Graph initialized successfully"); - self.globals().blank(); - self.globals().kv("Name", &output.name); - self.globals().kv("Path", output.path.normalize()); + let printer = self.printer(); + + printer.success("Space initialized"); + printer.blank(); + printer.kv("Name", &output.name); + printer.kv("Path", output.path.normalize()); } } diff --git a/crates/flow-cli/src/common.rs b/crates/flow-cli/src/common.rs index 7e2507a..b9b19f8 100644 --- a/crates/flow-cli/src/common.rs +++ b/crates/flow-cli/src/common.rs @@ -1,28 +1,17 @@ use clap::Args; -use console::{style, Emoji, Term}; -use flow_core::Space; -use miette::{miette, IntoDiagnostic, Result}; +use flow_core::{Config, Space}; +use miette::Result; use std::path::PathBuf; -// Emojis with fallbacks for terminals that don't support them -static SPARKLE: Emoji<'_, '_> = Emoji("✨ ", "* "); -static INFO: Emoji<'_, '_> = Emoji("ℹ️ ", "[i] "); -static SUCCESS: Emoji<'_, '_> = Emoji("✅ ", "[+] "); -static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "[!] "); -static ERROR: Emoji<'_, '_> = Emoji("❌ ", "[x] "); -static DEBUG: Emoji<'_, '_> = Emoji("🔍 ", "[?] "); -static ARROW: Emoji<'_, '_> = Emoji("→ ", "-> "); +use crate::{errors::Error, printer::Printer}; +/// Output formatting arguments - available for ALL commands. #[derive(Args, Debug, Clone)] -pub struct GlobalArgs { +pub struct OutputArgs { /// Output in JSON format #[arg(long, global = true)] pub json: bool, - /// Target specific space by name or path (overrides active space) - #[arg(long, global = true)] - pub space: Option, - /// Detailed logging #[arg(short, long, global = true)] pub verbose: bool, @@ -32,101 +21,61 @@ pub struct GlobalArgs { pub quiet: bool, } -impl GlobalArgs { - pub async fn load_space(&self) -> Result { - let name_or_path = self - .space - .as_ref() - .ok_or_else(|| miette!("Currently only commands with --space argument is supported."))?; - - let path = PathBuf::from(name_or_path); - if !path.exists() { - return Err(flow_core::Error::NotFound(path).into()); - } - - Space::load(path).await - } - - pub fn print(&self, message: impl Into) { - if !self.quiet && !self.json { - let _ = Term::stdout().write_line(message.into().as_str()); - } - } - - pub fn success(&self, message: impl Into) { - if !self.quiet && !self.json { - let _ = Term::stdout().write_line(&format!("{}{}", SUCCESS, style(message.into()).green().bold())); - } - } - - pub fn info(&self, message: impl Into) { - if !self.quiet && !self.json { - let _ = Term::stdout().write_line(&format!("{}{}", INFO, style(message.into()).cyan())); - } - } - - pub fn warning(&self, message: impl Into) { - if !self.quiet && !self.json { - let _ = Term::stdout().write_line(&format!("{}{}", WARN, style(message.into()).yellow().bold())); - } - } - - pub fn step(&self, message: impl Into) { - if !self.quiet && !self.json { - let _ = Term::stdout().write_line(&format!("{}{}", ARROW, style(message.into()).dim())); - } - } - - pub fn verbose(&self, message: impl Into) { - if self.verbose && !self.quiet && !self.json { - let _ = Term::stdout().write_line(&format!("{}{}", DEBUG, style(message.into()).dim())); - } +impl OutputArgs { + /// Create a printer from these output arguments. + #[must_use] + pub const fn printer(&self) -> Printer { + Printer::new(self.json, self.verbose, self.quiet) } +} - pub fn debug(&self, label: impl Into, value: impl Into) { - if self.verbose && !self.quiet && !self.json { - let _ = Term::stdout().write_line(&format!( - "{}{}: {}", - DEBUG, - style(label.into()).dim(), - style(value.into()).dim().italic() - )); - } - } +/// Arguments for commands that operate on existing spaces. +#[derive(Args, Debug, Clone)] +pub struct SpaceArgs { + /// Embedded output arguments + #[command(flatten)] + pub output: OutputArgs, - pub fn error(&self, message: impl Into) { - if !self.quiet && !self.json { - let _ = Term::stderr().write_line(&format!("{}{}", ERROR, style(message.into()).red().bold())); - } - } + /// Target specific space by name or path (overrides active space) + #[arg(long)] + pub space: Option, +} - pub fn heading(&self, heading: impl Into) { - if !self.quiet && !self.json { - let _ = Term::stdout().write_line(&format!("{}{}", SPARKLE, style(heading.into()).bold().underlined())); - } +impl SpaceArgs { + /// Create a printer from these space arguments. + #[must_use] + #[allow(dead_code)] + pub const fn printer(&self) -> Printer { + self.output.printer() } - pub fn kv(&self, key: impl Into, value: impl Into) { - if !self.quiet && !self.json { - let _ = Term::stdout().write_line(&format!( - " {}: {}", - style(key.into()).cyan().bold(), - style(value.into()).white() - )); + /// Load the space based on the provided arguments. + /// + /// Resolution order: + /// 1. If `--space` is provided and is a valid path, load from that path + /// 2. If `--space` is provided and matches a registered space name, load that space + /// 3. Otherwise, load the currently active space from config + /// + /// # Errors + /// + /// Returns an error if no space can be found or loaded. + #[allow(dead_code)] + pub async fn load_space(&self) -> Result { + if let Some(name_or_path) = &self.space { + let path = PathBuf::from(name_or_path); + if path.exists() { + return Space::load(path).await; + } } - } - pub fn blank(&self) { - if !self.quiet && !self.json { - let _ = Term::stdout().write_line(""); - } - } + let config = Config::load().await?; + let space = self + .space + .as_ref() + .and_then(|n| config.find(n)) + .or_else(|| config.active()) + .ok_or(Error::NoActiveSpace)?; - pub fn json(&self, value: &T) -> Result<()> { - if self.json { - let json = serde_json::to_string_pretty(value).into_diagnostic()?; - let _ = Term::stdout().write_line(&json); - } - Ok(()) + Space::load(&space.name).await } } diff --git a/crates/flow-cli/src/errors.rs b/crates/flow-cli/src/errors.rs index 3005eeb..ffde8ea 100644 --- a/crates/flow-cli/src/errors.rs +++ b/crates/flow-cli/src/errors.rs @@ -10,4 +10,12 @@ pub enum Error { #[error("Missing argument: {0}")] #[diagnostic(code(flow::missing_argument), url(docsrs))] MissingArgument(String), + + #[error("There is no active space set")] + #[diagnostic( + code(flow::missing_argument), + url(docsrs), + help(". Use --space to specify one specifically or register one with `flow register`.") + )] + NoActiveSpace, } diff --git a/crates/flow-cli/src/lib.rs b/crates/flow-cli/src/lib.rs index efb8a25..b01e215 100644 --- a/crates/flow-cli/src/lib.rs +++ b/crates/flow-cli/src/lib.rs @@ -7,6 +7,7 @@ mod commands; mod common; mod errors; mod extensions; +mod printer; #[derive(Subcommand)] pub enum Commands { diff --git a/crates/flow-cli/src/printer.rs b/crates/flow-cli/src/printer.rs new file mode 100644 index 0000000..77a76b1 --- /dev/null +++ b/crates/flow-cli/src/printer.rs @@ -0,0 +1,114 @@ +use console::{style, Emoji, Term}; +use miette::{IntoDiagnostic, Result}; + +// Emojis with fallbacks for terminals that don't support them +static SPARKLE: Emoji<'_, '_> = Emoji("✨ ", "* "); +static INFO: Emoji<'_, '_> = Emoji("ℹ️ ", "[i] "); +static SUCCESS: Emoji<'_, '_> = Emoji("✅ ", "[+] "); +static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "[!] "); +static ERROR: Emoji<'_, '_> = Emoji("❌ ", "[x] "); +static DEBUG: Emoji<'_, '_> = Emoji("🔍 ", "[?] "); +static ARROW: Emoji<'_, '_> = Emoji("→ ", "-> "); + +/// Handles all CLI output printing with support for JSON, verbose, and quiet modes. +#[derive(Debug, Clone)] +pub struct Printer { + json: bool, + verbose: bool, + quiet: bool, +} + +impl Printer { + #[must_use] + pub const fn new(json: bool, verbose: bool, quiet: bool) -> Self { + Self { json, verbose, quiet } + } + + #[must_use] + pub const fn is_json(&self) -> bool { + self.json + } + + pub fn print(&self, message: impl Into) { + if !self.quiet && !self.json { + let _ = Term::stdout().write_line(message.into().as_str()); + } + } + + pub fn success(&self, message: impl Into) { + if !self.quiet && !self.json { + let _ = Term::stdout().write_line(&format!("{}{}", SUCCESS, style(message.into()).green().bold())); + } + } + + pub fn info(&self, message: impl Into) { + if !self.quiet && !self.json { + let _ = Term::stdout().write_line(&format!("{}{}", INFO, style(message.into()).cyan())); + } + } + + pub fn warning(&self, message: impl Into) { + if !self.quiet && !self.json { + let _ = Term::stdout().write_line(&format!("{}{}", WARN, style(message.into()).yellow().bold())); + } + } + + pub fn step(&self, message: impl Into) { + if !self.quiet && !self.json { + let _ = Term::stdout().write_line(&format!("{}{}", ARROW, style(message.into()).dim())); + } + } + + pub fn verbose(&self, message: impl Into) { + if self.verbose && !self.quiet && !self.json { + let _ = Term::stdout().write_line(&format!("{}{}", DEBUG, style(message.into()).dim())); + } + } + + pub fn debug(&self, label: impl Into, value: impl Into) { + if self.verbose && !self.quiet && !self.json { + let _ = Term::stdout().write_line(&format!( + "{}{}: {}", + DEBUG, + style(label.into()).dim(), + style(value.into()).dim().italic() + )); + } + } + + pub fn error(&self, message: impl Into) { + if !self.quiet && !self.json { + let _ = Term::stderr().write_line(&format!("{}{}", ERROR, style(message.into()).red().bold())); + } + } + + pub fn heading(&self, heading: impl Into) { + if !self.quiet && !self.json { + let _ = Term::stdout().write_line(&format!("{}{}", SPARKLE, style(heading.into()).bold().underlined())); + } + } + + pub fn kv(&self, key: impl Into, value: impl Into) { + if !self.quiet && !self.json { + let _ = Term::stdout().write_line(&format!( + " {}: {}", + style(key.into()).cyan().bold(), + style(value.into()).white() + )); + } + } + + pub fn blank(&self) { + if !self.quiet && !self.json { + let _ = Term::stdout().write_line(""); + } + } + + pub fn json(&self, value: &T) -> Result<()> { + if self.json { + let json = serde_json::to_string_pretty(value).into_diagnostic()?; + let _ = Term::stdout().write_line(&json); + } + Ok(()) + } +} diff --git a/crates/flow/src/main.rs b/crates/flow/src/main.rs index e1edd47..adc2114 100644 --- a/crates/flow/src/main.rs +++ b/crates/flow/src/main.rs @@ -28,10 +28,6 @@ fn version() -> &'static str { #[command(version = version())] #[command(about = "Flow - Note taking for developers")] struct Flow { - /// Space to use (registered name or path), falls back to registered default space if not set. - #[arg(short, long, global = true)] - space: Option, - /// Sub-commands. #[command(subcommand)] command: Option, From 33d5fa9bb3b8d87cba090fc94ecc64e0db5e46e5 Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Wed, 21 Jan 2026 20:50:45 +0100 Subject: [PATCH 16/19] refactor(cli): Parsed arguments are now consumed by the command implemenation --- crates/flow-cli/src/lib.rs | 4 ++-- crates/flow/src/main.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/flow-cli/src/lib.rs b/crates/flow-cli/src/lib.rs index b01e215..c4538db 100644 --- a/crates/flow-cli/src/lib.rs +++ b/crates/flow-cli/src/lib.rs @@ -19,9 +19,9 @@ pub enum Commands { /// # Errors /// /// Returns an error if the command execution fails. -pub async fn run(cmd: &Commands) -> Result<()> { +pub async fn run(cmd: Commands) -> Result<()> { match cmd { - Commands::Init(args) => init::Init::new(args.clone()).run().await?, + Commands::Init(args) => init::Init::new(args).run().await?, } Ok(()) diff --git a/crates/flow/src/main.rs b/crates/flow/src/main.rs index adc2114..6f3b019 100644 --- a/crates/flow/src/main.rs +++ b/crates/flow/src/main.rs @@ -59,7 +59,7 @@ async fn main() -> Result<()> { Some(Commands::Gui) => flow_gui::run().await?, #[cfg(feature = "server")] Some(Commands::Serve) => flow_server::run().await?, - Some(Commands::Cli(cmd)) => flow_cli::run(&cmd).await?, + Some(Commands::Cli(cmd)) => flow_cli::run(cmd).await?, None => { // TODO: Maybe later we can just launch the UI the user selected in the configuration. From 0f355811c0cc050591bb187a4605b36ec24ebd85 Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Wed, 21 Jan 2026 20:51:14 +0100 Subject: [PATCH 17/19] refactor(cli): Printing now accepts string references --- crates/flow-cli/src/printer.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/flow-cli/src/printer.rs b/crates/flow-cli/src/printer.rs index 77a76b1..18b0c52 100644 --- a/crates/flow-cli/src/printer.rs +++ b/crates/flow-cli/src/printer.rs @@ -29,39 +29,39 @@ impl Printer { self.json } - pub fn print(&self, message: impl Into) { + pub fn print(&self, message: impl AsRef) { if !self.quiet && !self.json { - let _ = Term::stdout().write_line(message.into().as_str()); + let _ = Term::stdout().write_line(message.as_ref()); } } - pub fn success(&self, message: impl Into) { + pub fn success(&self, message: impl AsRef) { if !self.quiet && !self.json { - let _ = Term::stdout().write_line(&format!("{}{}", SUCCESS, style(message.into()).green().bold())); + let _ = Term::stdout().write_line(&format!("{}{}", SUCCESS, style(message.as_ref()).green().bold())); } } - pub fn info(&self, message: impl Into) { + pub fn info(&self, message: impl AsRef) { if !self.quiet && !self.json { - let _ = Term::stdout().write_line(&format!("{}{}", INFO, style(message.into()).cyan())); + let _ = Term::stdout().write_line(&format!("{}{}", INFO, style(message.as_ref()).cyan())); } } - pub fn warning(&self, message: impl Into) { + pub fn warning(&self, message: impl AsRef) { if !self.quiet && !self.json { - let _ = Term::stdout().write_line(&format!("{}{}", WARN, style(message.into()).yellow().bold())); + let _ = Term::stdout().write_line(&format!("{}{}", WARN, style(message.as_ref()).yellow().bold())); } } - pub fn step(&self, message: impl Into) { + pub fn step(&self, message: impl AsRef) { if !self.quiet && !self.json { - let _ = Term::stdout().write_line(&format!("{}{}", ARROW, style(message.into()).dim())); + let _ = Term::stdout().write_line(&format!("{}{}", ARROW, style(message.as_ref()).dim())); } } - pub fn verbose(&self, message: impl Into) { + pub fn verbose(&self, message: impl AsRef) { if self.verbose && !self.quiet && !self.json { - let _ = Term::stdout().write_line(&format!("{}{}", DEBUG, style(message.into()).dim())); + let _ = Term::stdout().write_line(&format!("{}{}", DEBUG, style(message.as_ref()).dim())); } } @@ -76,9 +76,9 @@ impl Printer { } } - pub fn error(&self, message: impl Into) { + pub fn error(&self, message: impl AsRef) { if !self.quiet && !self.json { - let _ = Term::stderr().write_line(&format!("{}{}", ERROR, style(message.into()).red().bold())); + let _ = Term::stderr().write_line(&format!("{}{}", ERROR, style(message.as_ref()).red().bold())); } } From d016c3bcb1ae8f381f5f3af727f2a22720eec3d5 Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Wed, 21 Jan 2026 20:52:10 +0100 Subject: [PATCH 18/19] refactor(space): Use Cow::Borrowed for static version string in metadata --- crates/flow-core/src/space/default.rs | 7 +++++-- crates/flow-core/src/space/metadata.rs | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/flow-core/src/space/default.rs b/crates/flow-core/src/space/default.rs index 6b50121..0382483 100644 --- a/crates/flow-core/src/space/default.rs +++ b/crates/flow-core/src/space/default.rs @@ -37,7 +37,10 @@ //! let space = DefaultSpace::init(fs, "./my-space", "personal").await?; //! ``` -use std::path::{Path, PathBuf}; +use std::{ + borrow::Cow, + path::{Path, PathBuf}, +}; use loro::LoroDoc; use miette::{ensure, IntoDiagnostic, Result}; @@ -187,7 +190,7 @@ impl Space for DefaultSpace { let metadata = Metadata { name: name.into(), - version: env!("CARGO_PKG_VERSION").to_string(), + version: Cow::Borrowed(env!("CARGO_PKG_VERSION")), }; let metadata_json = serde_json::to_string_pretty(&metadata).into_diagnostic()?; let metadata_path = flow_dir.join(METADATA_FILE); diff --git a/crates/flow-core/src/space/metadata.rs b/crates/flow-core/src/space/metadata.rs index 2a70c4f..98a3d60 100644 --- a/crates/flow-core/src/space/metadata.rs +++ b/crates/flow-core/src/space/metadata.rs @@ -16,6 +16,8 @@ //! allowing future versions of Flow to add new fields without breaking //! existing spaces. +use std::borrow::Cow; + use serde::{Deserialize, Serialize}; /// Metadata about a Flow space. @@ -76,5 +78,5 @@ pub struct Metadata { /// The version of Flow that created this space. /// /// This can be used for future migrations if the space format changes. - pub version: String, + pub version: Cow<'static, str>, } From 62098da77b042c3fbaf83cb7da10f1031a8aa32d Mon Sep 17 00:00:00 2001 From: Michael Baudler Date: Wed, 21 Jan 2026 20:55:10 +0100 Subject: [PATCH 19/19] docs: Changes PathNotFound to NotFound in documentation --- crates/flow-core/src/errors.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/flow-core/src/errors.rs b/crates/flow-core/src/errors.rs index d659996..f4739e7 100644 --- a/crates/flow-core/src/errors.rs +++ b/crates/flow-core/src/errors.rs @@ -25,7 +25,7 @@ //! use std::path::PathBuf; //! //! // Errors can be created directly -//! let error = Error::PathNotFound(PathBuf::from("/nonexistent/path")); +//! let error = Error::NotFound(PathBuf::from("/nonexistent/path")); //! //! // They implement Display for human-readable messages //! println!("Error: {}", error); @@ -49,7 +49,7 @@ use crate::space::Locator; /// | Variant | Error Code | Description | /// |---------|------------|-------------| /// | [`Io`](Error::Io) | `flow::io_error` | Low-level filesystem errors | -/// | [`PathNotFound`](Error::PathNotFound) | `flow::path_not_found` | Path does not exist | +/// | [`NotFound`](Error::NotFound) | `flow::path_not_found` | Path does not exist | /// | [`NotADirectory`](Error::NotADirectory) | `flow::not_a_directory` | Path is not a directory | /// | [`AlreadyExists`](Error::AlreadyExists) | `flow::already_exists` | Space already exists | /// | [`DirectoryNotEmpty`](Error::DirectoryNotEmpty) | `flow::directory_not_empty` | Directory has contents | @@ -64,7 +64,7 @@ use crate::space::Locator; /// /// fn handle_error(error: Error) { /// match error { -/// Error::PathNotFound(path) => { +/// Error::NotFound(path) => { /// eprintln!("Path not found: {}", path.display()); /// } /// Error::AlreadyExists(path) => {