diff --git a/Cargo.lock b/Cargo.lock index 0310dbd..fd03b04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.5" @@ -47,7 +62,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,9 +73,54 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cc" version = "1.5.1" @@ -71,6 +131,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + [[package]] name = "clap" version = "4.6.7" @@ -102,7 +168,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -117,24 +183,133 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "diff" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "failure" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d32e9bd16cc02eae7db7ef620b392808b89f6a5e16bb3497d159c6b92a0f4f86" +dependencies = [ + "backtrace", + "failure_derive", +] + +[[package]] +name = "failure_derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4da3c766cd7a0db8242e326e9e4e081edd567072893ed320008189715366a4" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "synstructure", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -147,12 +322,162 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.2", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[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 = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nine" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7eca0c850b4d39133ee5a1c0939027b0ef5c27c1927d90df3ce796c7ebdf49" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "failure", + "serde", + "serde_derive", +] + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.13.2", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[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 = "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -192,6 +517,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "ork-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "nine", + "notify", + "org-ast", + "org-parser", + "parking_lot", + "tempfile", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "orkmode-core" version = "0.1.0" @@ -204,6 +546,35 @@ dependencies = [ "thiserror", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -232,6 +603,21 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "regex" version = "1.13.1" @@ -261,6 +647,40 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.229" @@ -288,7 +708,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -304,18 +724,75 @@ dependencies = [ "zmij", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.6" @@ -327,6 +804,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.21" @@ -344,7 +846,105 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio 1.2.3", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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.119", +] + +[[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.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -363,18 +963,64 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -384,6 +1030,63 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 2297c5b..96e8392 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/org-ast", "crates/orkmode-core", "crates/ork-cli", + "crates/ork-server", ] [workspace.package] diff --git a/crates/ork-server/Cargo.toml b/crates/ork-server/Cargo.toml new file mode 100644 index 0000000..b1d887b --- /dev/null +++ b/crates/ork-server/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "ork-server" +version = "0.1.0" +edition = "2021" +license = "GPL-3.0-or-later" +description = "9P server for orkmode" + +[dependencies] +org-ast = { path = "../org-ast" } +org-parser = { path = "../org-parser" } + +# 9P protocol +nine = "0.2" + +# Async runtime +tokio = { version = "1", features = ["full"] } + +# Error handling +thiserror = "2" +anyhow = "1" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# File watching +notify = "6" + +# Utilities +parking_lot = "0.12" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/ork-server/src/lib.rs b/crates/ork-server/src/lib.rs new file mode 100644 index 0000000..f79b852 --- /dev/null +++ b/crates/ork-server/src/lib.rs @@ -0,0 +1,9 @@ +//! Orkmode 9P server. +//! +//! Exposes org documents via 9P filesystem protocol. + +pub mod virtfs; +pub mod state; +pub mod namespace; + +pub use state::OrkState; diff --git a/crates/ork-server/src/namespace.rs b/crates/ork-server/src/namespace.rs new file mode 100644 index 0000000..62c62bc --- /dev/null +++ b/crates/ork-server/src/namespace.rs @@ -0,0 +1,292 @@ +//! Build the 9P namespace from server state. + +use crate::state::OrkState; +use crate::virtfs::{self, FsNode}; +use std::sync::Arc; + +/// Build the orkmode 9P namespace. +pub fn build_namespace(state: Arc) -> FsNode { + let s = state.clone(); + let s2 = state.clone(); + + // Root directory with static files + dynamic doc directories + virtfs::dir_dynamic("/", + vec![ + // ctl - control commands + virtfs::file("ctl", 0o222, virtfs::rdwr({ + let state = state.clone(); + move |data| { + let cmd = String::from_utf8_lossy(data); + state.handle_ctl(&cmd) + .map(|s| s.into_bytes()) + } + })), + + // idx - document index + virtfs::file("idx", 0o444, virtfs::read({ + let state = state.clone(); + move || Ok(state.doc_index().into_bytes()) + })), + + // keywords - TODO keywords + virtfs::file("keywords", 0o444, virtfs::read({ + let state = state.clone(); + move || Ok(state.keywords().into_bytes()) + })), + + // agenda - computed views + virtfs::dir("agenda", vec![ + virtfs::file("todos", 0o444, virtfs::read({ + let state = state.clone(); + move || Ok(build_todos_view(&state).into_bytes()) + })), + virtfs::file("today", 0o444, virtfs::read({ + let state = state.clone(); + move || Ok(build_today_view(&state).into_bytes()) + })), + virtfs::file("week", 0o444, virtfs::read({ + let state = state.clone(); + move || Ok(build_week_view(&state).into_bytes()) + })), + ]), + ], + // Dynamic children: document directories + move || Ok(s.doc_names()), + move |name| { + let doc_name = name.to_string(); + Some(build_doc_node(s2.clone(), &doc_name)) + }, + ) +} + +/// Build a document node. +fn build_doc_node(state: Arc, doc_name: &str) -> FsNode { + let name = doc_name.to_string(); + let s = state.clone(); + let s2 = state.clone(); + let n = name.clone(); + let n2 = name.clone(); + + virtfs::dir_dynamic(&name, + vec![ + // raw - full org content + virtfs::file("raw", 0o644, virtfs::rw( + { + let state = state.clone(); + let name = name.clone(); + move || { + state.doc_raw(&name) + .map(|s| s.into_bytes()) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found")) + } + }, + { + let state = state.clone(); + let name = name.clone(); + move |data| { + let content = String::from_utf8_lossy(data); + state.write_doc_raw(&name, &content) + } + }, + )), + + // meta - document metadata + virtfs::file("meta", 0o444, virtfs::read({ + let state = state.clone(); + let name = name.clone(); + move || { + state.doc_meta(&name) + .map(|s| s.into_bytes()) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found")) + } + })), + ], + // Dynamic section directories + move || Ok(s.section_ids(&n)), + move |id| { + Some(build_section_node(s2.clone(), &n2, id)) + }, + ) +} + +/// Build a section node (recursive for children). +fn build_section_node(state: Arc, doc_name: &str, section_id: &str) -> FsNode { + let doc = doc_name.to_string(); + let id = section_id.to_string(); + + let s1 = state.clone(); + let s2 = state.clone(); + let s3 = state.clone(); + let s4 = state.clone(); + let s5 = state.clone(); + let s6 = state.clone(); + let s7 = state.clone(); + + let d1 = doc.clone(); + let d2 = doc.clone(); + let d3 = doc.clone(); + let d4 = doc.clone(); + let d5 = doc.clone(); + let d6 = doc.clone(); + let d7 = doc.clone(); + + let i1 = id.clone(); + let i2 = id.clone(); + let i3 = id.clone(); + let i4 = id.clone(); + let i5 = id.clone(); + let i6 = id.clone(); + + virtfs::dir_dynamic(&id, + vec![ + // headline - full reconstructed headline + virtfs::file("headline", 0o444, virtfs::read(move || { + s1.section_headline(&d1, &i1) + .map(|s| format!("{}\n", s).into_bytes()) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found")) + })), + + // title + virtfs::file("title", 0o644, virtfs::read(move || { + s2.section_title(&d2, &i2) + .map(|s| format!("{}\n", s).into_bytes()) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found")) + })), + + // keyword (TODO/DONE/etc) + virtfs::file("keyword", 0o644, virtfs::read(move || { + Ok(s3.section_keyword(&d3, &i3) + .map(|s| format!("{}\n", s).into_bytes()) + .unwrap_or_else(|| b"\n".to_vec())) + })), + + // priority + virtfs::file("priority", 0o644, virtfs::read(move || { + Ok(s4.section_priority(&d4, &i4) + .map(|s| format!("{}\n", s).into_bytes()) + .unwrap_or_else(|| b"\n".to_vec())) + })), + + // tags + virtfs::file("tags", 0o644, virtfs::read(move || { + Ok(s5.section_tags(&d5, &i5) + .map(|s| format!("{}\n", s).into_bytes()) + .unwrap_or_else(|| b"\n".to_vec())) + })), + ], + // children - nested sections + move || Ok(s6.child_ids(&d6, &i6)), + move |child_id| { + Some(build_section_node(s7.clone(), &d7, child_id)) + }, + ) +} + +// ═══════════════════════════════════════════════════════════════════ +// Agenda Views +// ═══════════════════════════════════════════════════════════════════ + +fn build_todos_view(state: &OrkState) -> String { + let mut lines = Vec::new(); + + for doc_name in state.doc_names() { + if let Some(doc_state) = state.get_doc(&doc_name) { + for section in doc_state.doc.all_sections() { + if section.is_todo() { + let id = if let Some(cid) = section.custom_id() { + cid.to_string() + } else { + section.headline.title_text().to_lowercase().replace(' ', "-") + }; + let kw = section.headline.keyword.as_ref() + .map(|k| k.keyword.as_str()) + .unwrap_or(""); + lines.push(format!("{}\t{}\t{}\t{}", + id, doc_name, section.headline.title_text(), kw)); + } + } + } + } + + lines.sort(); + lines.join("\n") + "\n" +} + +fn build_today_view(state: &OrkState) -> String { + // TODO: implement with actual date filtering + build_todos_view(state) +} + +fn build_week_view(state: &OrkState) -> String { + // TODO: implement with actual date filtering + build_todos_view(state) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_namespace_structure() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("test.org"), "#+TITLE: Test\n* TODO Task\n").unwrap(); + + let state = Arc::new(OrkState::new(dir.path()).unwrap()); + let root = build_namespace(state); + + // Check root files + assert!(root.walk("ctl").is_some()); + assert!(root.walk("idx").is_some()); + assert!(root.walk("keywords").is_some()); + assert!(root.walk("agenda").is_some()); + assert!(root.walk("agenda/todos").is_some()); + + // Check document + assert!(root.walk("test").is_some()); + assert!(root.walk("test/raw").is_some()); + assert!(root.walk("test/meta").is_some()); + + // Check section + assert!(root.walk("test/task").is_some()); + assert!(root.walk("test/task/title").is_some()); + assert!(root.walk("test/task/keyword").is_some()); + } + + #[test] + fn test_read_files() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("work.org"), + "#+TITLE: Work Tasks\n* TODO [#A] Important :urgent:\nSome body text\n").unwrap(); + + let state = Arc::new(OrkState::new(dir.path()).unwrap()); + let root = build_namespace(state); + + // Read idx + let idx = root.walk("idx").unwrap(); + let content = String::from_utf8(idx.read().unwrap()).unwrap(); + assert!(content.contains("work")); + assert!(content.contains("Work Tasks")); + + // Read section title + let title = root.walk("work/important/title").unwrap(); + let content = String::from_utf8(title.read().unwrap()).unwrap(); + assert_eq!(content.trim(), "Important"); + + // Read keyword + let kw = root.walk("work/important/keyword").unwrap(); + let content = String::from_utf8(kw.read().unwrap()).unwrap(); + assert_eq!(content.trim(), "TODO"); + + // Read priority + let pri = root.walk("work/important/priority").unwrap(); + let content = String::from_utf8(pri.read().unwrap()).unwrap(); + assert_eq!(content.trim(), "A"); + + // Read tags + let tags = root.walk("work/important/tags").unwrap(); + let content = String::from_utf8(tags.read().unwrap()).unwrap(); + assert_eq!(content.trim(), "urgent"); + } +} diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs new file mode 100644 index 0000000..3956e8c --- /dev/null +++ b/crates/ork-server/src/state.rs @@ -0,0 +1,296 @@ +//! Server state: manages org documents and their parsed state. + +use org_ast::Document; +use org_parser::Parser; +use parking_lot::RwLock; +use std::collections::HashMap; +use std::fs; +use std::io::{self, Result}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// Parsed document with metadata. +pub struct DocState { + pub path: PathBuf, + pub content: String, + pub doc: Document, +} + +/// Server state: all loaded documents. +pub struct OrkState { + /// Directory containing org files. + pub org_dir: PathBuf, + + /// Parsed documents by name (without .org extension). + docs: RwLock>>, + + /// Parser instance. + parser: RwLock, +} + +impl OrkState { + /// Create new state watching the given directory. + pub fn new(org_dir: impl AsRef) -> Result { + let org_dir = org_dir.as_ref().to_path_buf(); + if !org_dir.is_dir() { + return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory")); + } + + let state = Self { + org_dir, + docs: RwLock::new(HashMap::new()), + parser: RwLock::new(Parser::new()), + }; + + state.scan()?; + Ok(state) + } + + /// Scan org directory for files. + pub fn scan(&self) -> Result<()> { + let mut docs = self.docs.write(); + docs.clear(); + + for entry in fs::read_dir(&self.org_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().map_or(false, |e| e == "org") { + if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { + match self.load_doc(&path) { + Ok(doc_state) => { + docs.insert(name.to_string(), Arc::new(doc_state)); + } + Err(e) => { + eprintln!("warning: failed to load {}: {}", path.display(), e); + } + } + } + } + } + + Ok(()) + } + + /// Load and parse a single document. + fn load_doc(&self, path: &Path) -> Result { + let content = fs::read_to_string(path)?; + let doc = self.parser.write().parse(&content) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + + Ok(DocState { + path: path.to_path_buf(), + content, + doc, + }) + } + + /// List document names. + pub fn doc_names(&self) -> Vec { + self.docs.read().keys().cloned().collect() + } + + /// Get a document by name. + pub fn get_doc(&self, name: &str) -> Option> { + self.docs.read().get(name).cloned() + } + + /// Get document index: name\tpath\ttitle. + pub fn doc_index(&self) -> String { + let docs = self.docs.read(); + let mut lines: Vec = docs.iter() + .map(|(name, state)| { + let title = state.doc.title().unwrap_or("-"); + format!("{}\t{}\t{}", name, state.path.display(), title) + }) + .collect(); + lines.sort(); + lines.join("\n") + "\n" + } + + /// Get raw content of a document. + pub fn doc_raw(&self, name: &str) -> Option { + self.get_doc(name).map(|d| d.content.clone()) + } + + /// Write raw content to a document. + pub fn write_doc_raw(&self, name: &str, content: &str) -> Result<()> { + let doc_state = self.get_doc(name) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?; + + // Parse first to validate + let new_doc = self.parser.write().parse(content) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + + // Write to file + fs::write(&doc_state.path, content)?; + + // Update state + let new_state = Arc::new(DocState { + path: doc_state.path.clone(), + content: content.to_string(), + doc: new_doc, + }); + self.docs.write().insert(name.to_string(), new_state); + + Ok(()) + } + + /// Get document metadata. + pub fn doc_meta(&self, name: &str) -> Option { + self.get_doc(name).map(|d| { + let title = d.doc.title().unwrap_or(""); + let author = d.doc.author().unwrap_or(""); + let filetags = d.doc.settings.filetags.join(":"); + format!("title\t{}\nauthor\t{}\nfiletags\t{}\n", title, author, filetags) + }) + } + + /// Get all TODO keywords configuration. + pub fn keywords(&self) -> String { + // Return default TODO keywords + // TODO: aggregate from all docs or use config + "TODO NEXT WAITING | DONE CANCELLED\n".to_string() + } + + /// Handle control commands. + pub fn handle_ctl(&self, cmd: &str) -> Result { + let cmd = cmd.trim(); + match cmd { + "reload" | "rescan" => { + self.scan()?; + Ok("ok\n".to_string()) + } + _ => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unknown command: {}", cmd))), + } + } +} + +// ═══════════════════════════════════════════════════════════════════ +// Section Access +// ═══════════════════════════════════════════════════════════════════ + +impl OrkState { + /// Get section IDs for a document (top-level only). + pub fn section_ids(&self, doc_name: &str) -> Vec { + self.get_doc(doc_name) + .map(|d| { + d.doc.sections.iter() + .map(|s| self.section_id(s)) + .collect() + }) + .unwrap_or_default() + } + + /// Get child section IDs. + pub fn child_ids(&self, doc_name: &str, parent_id: &str) -> Vec { + self.get_doc(doc_name) + .and_then(|d| { + d.doc.all_sections() + .find(|s| self.section_id(s) == parent_id) + .map(|s| { + s.children.iter() + .map(|c| self.section_id(c)) + .collect() + }) + }) + .unwrap_or_default() + } + + /// Generate section ID: CUSTOM_ID > ID > slugified title. + fn section_id(&self, section: &org_ast::Section) -> String { + if let Some(id) = section.custom_id() { + return id.to_string(); + } + if let Some(id) = section.id() { + return id.to_string(); + } + // Slugify title + let title = section.headline.title_text(); + slugify(&title) + } + + /// Find section by ID in document. + pub fn find_section<'a>(&self, doc: &'a Document, id: &str) -> Option<&'a org_ast::Section> { + doc.all_sections().find(|s| self.section_id(s) == id) + } + + /// Get section headline. + pub fn section_headline(&self, doc_name: &str, id: &str) -> Option { + self.get_doc(doc_name).and_then(|d| { + self.find_section(&d.doc, id).map(|s| { + // Reconstruct headline + let mut line = "*".repeat(s.headline.level as usize); + line.push(' '); + if let Some(kw) = &s.headline.keyword { + line.push_str(&kw.keyword); + line.push(' '); + } + if let Some(p) = s.headline.priority { + line.push_str(&format!("[#{}] ", p.as_char())); + } + line.push_str(&s.headline.title_text()); + if !s.headline.tags.is_empty() { + line.push_str(&format!(" :{}:", s.headline.tags.join(":"))); + } + line + }) + }) + } + + /// Get section title. + pub fn section_title(&self, doc_name: &str, id: &str) -> Option { + self.get_doc(doc_name).and_then(|d| { + self.find_section(&d.doc, id).map(|s| s.headline.title_text()) + }) + } + + /// Get section keyword. + pub fn section_keyword(&self, doc_name: &str, id: &str) -> Option { + self.get_doc(doc_name).and_then(|d| { + self.find_section(&d.doc, id).and_then(|s| { + s.headline.keyword.as_ref().map(|k| k.keyword.clone()) + }) + }) + } + + /// Get section priority. + pub fn section_priority(&self, doc_name: &str, id: &str) -> Option { + self.get_doc(doc_name).and_then(|d| { + self.find_section(&d.doc, id).and_then(|s| { + s.headline.priority.map(|p| p.as_char().to_string()) + }) + }) + } + + /// Get section tags. + pub fn section_tags(&self, doc_name: &str, id: &str) -> Option { + self.get_doc(doc_name).and_then(|d| { + self.find_section(&d.doc, id).map(|s| s.headline.tags.join(":")) + }) + } +} + +/// Slugify a string for use as an ID. +fn slugify(s: &str) -> String { + s.to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_slugify() { + assert_eq!(slugify("Hello World"), "hello-world"); + assert_eq!(slugify("TODO: Fix bug #123"), "todo-fix-bug-123"); + assert_eq!(slugify(" multiple spaces "), "multiple-spaces"); + } +} diff --git a/crates/ork-server/src/virtfs.rs b/crates/ork-server/src/virtfs.rs new file mode 100644 index 0000000..9a0dc62 --- /dev/null +++ b/crates/ork-server/src/virtfs.rs @@ -0,0 +1,376 @@ +//! Declarative virtual filesystem EDSL. +//! +//! Port of ollie's virtfs pattern: declare filesystem structure statically, +//! populate dynamically via closures. +//! +//! # Example +//! +//! ```ignore +//! let fs = dir("/", +//! file("version", 0o444, read(|| Ok(b"1.0\n".to_vec()))), +//! file("ctl", 0o222, rdwr(|data| { +//! // handle command, return response +//! Ok(b"ok\n".to_vec()) +//! })), +//! each("docs", || list_docs(), |name| { +//! dir(&name, +//! file("title", 0o444, read(move || get_title(&name))), +//! ) +//! }), +//! ); +//! ``` + +use std::collections::HashMap; +use std::io::{self, Result}; +use std::sync::Arc; + +/// File mode (Unix permissions). +pub type Mode = u32; + +/// Read handler: returns file content. +pub type ReadFn = Arc Result> + Send + Sync>; + +/// Write handler: receives data, returns Ok on success. +pub type WriteFn = Arc Result<()> + Send + Sync>; + +/// Rdwr handler: write triggers computation, returns response. +pub type RdwrFn = Arc Result> + Send + Sync>; + +/// List handler: returns dynamic child names. +pub type ListFn = Arc Result> + Send + Sync>; + +/// Child builder: given a name, returns the node for that child. +pub type ChildFn = Arc Option + Send + Sync>; + +/// A node in the virtual filesystem. +#[derive(Clone)] +pub struct FsNode { + pub name: String, + pub mode: Mode, + pub is_dir: bool, + + // File operations + pub read_fn: Option, + pub write_fn: Option, + pub rdwr_fn: Option, + + // Directory operations + pub children: HashMap, // Static children + pub list_fn: Option, // Dynamic child listing + pub child_fn: Option, // Dynamic child builder +} + +impl FsNode { + /// Create an empty node. + pub fn new(name: &str, mode: Mode, is_dir: bool) -> Self { + Self { + name: name.to_string(), + mode, + is_dir, + read_fn: None, + write_fn: None, + rdwr_fn: None, + children: HashMap::new(), + list_fn: None, + child_fn: None, + } + } + + /// Walk to a child node by path. + pub fn walk(&self, path: &str) -> Option { + if path.is_empty() || path == "." { + return Some(self.clone()); + } + + let path = path.trim_start_matches('/'); + let (first, rest) = match path.find('/') { + Some(i) => (&path[..i], &path[i + 1..]), + None => (path, ""), + }; + + // Try static children first + if let Some(child) = self.children.get(first) { + if rest.is_empty() { + return Some(child.clone()); + } else { + return child.walk(rest); + } + } + + // Try dynamic child builder + if let Some(ref child_fn) = self.child_fn { + if let Some(child) = child_fn(first) { + if rest.is_empty() { + return Some(child); + } else { + return child.walk(rest); + } + } + } + + None + } + + /// List children of this directory. + pub fn list(&self) -> Result> { + if !self.is_dir { + return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory")); + } + + let mut names: Vec = self.children.keys().cloned().collect(); + + // Add dynamic children + if let Some(ref list_fn) = self.list_fn { + let dynamic = list_fn()?; + names.extend(dynamic); + } + + names.sort(); + names.dedup(); + Ok(names) + } + + /// Read file content. + pub fn read(&self) -> Result> { + if let Some(ref f) = self.read_fn { + f() + } else { + Err(io::Error::new(io::ErrorKind::PermissionDenied, "not readable")) + } + } + + /// Write to file. + pub fn write(&self, data: &[u8]) -> Result<()> { + if let Some(ref f) = self.write_fn { + f(data) + } else { + Err(io::Error::new(io::ErrorKind::PermissionDenied, "not writable")) + } + } + + /// Rdwr: write then read (for command files). + pub fn rdwr(&self, data: &[u8]) -> Result> { + if let Some(ref f) = self.rdwr_fn { + f(data) + } else { + Err(io::Error::new(io::ErrorKind::PermissionDenied, "not rdwr")) + } + } + + /// Check if readable. + pub fn can_read(&self) -> bool { + self.read_fn.is_some() || self.is_dir + } + + /// Check if writable. + pub fn can_write(&self) -> bool { + self.write_fn.is_some() || self.rdwr_fn.is_some() + } +} + +impl std::fmt::Debug for FsNode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FsNode") + .field("name", &self.name) + .field("mode", &format!("{:04o}", self.mode)) + .field("is_dir", &self.is_dir) + .field("children", &self.children.keys().collect::>()) + .finish() + } +} + +// ═══════════════════════════════════════════════════════════════════ +// EDSL Constructors +// ═══════════════════════════════════════════════════════════════════ + +/// Create a directory node with static children. +pub fn dir(name: &str, children: Vec) -> FsNode { + let mut node = FsNode::new(name, 0o755, true); + for child in children { + node.children.insert(child.name.clone(), child); + } + node +} + +/// Create a directory node with dynamic children added to root. +/// Unlike `each`, these children appear as siblings, not inside a named container. +pub fn dir_dynamic(name: &str, static_children: Vec, list_fn: L, child_fn: C) -> FsNode +where + L: Fn() -> Result> + Send + Sync + 'static, + C: Fn(&str) -> Option + Send + Sync + 'static, +{ + let mut node = FsNode::new(name, 0o755, true); + for child in static_children { + node.children.insert(child.name.clone(), child); + } + node.list_fn = Some(Arc::new(list_fn)); + node.child_fn = Some(Arc::new(child_fn)); + node +} + +/// Create a file node with the given options. +pub fn file(name: &str, mode: Mode, opts: Vec) -> FsNode { + let mut node = FsNode::new(name, mode, false); + for opt in opts { + match opt { + FileOpt::Read(f) => node.read_fn = Some(f), + FileOpt::Write(f) => node.write_fn = Some(f), + FileOpt::Rdwr(f) => node.rdwr_fn = Some(f), + } + } + node +} + +/// Create a dynamic directory: children listed by list_fn, built by child_fn. +pub fn each(name: &str, list_fn: L, child_fn: C) -> FsNode +where + L: Fn() -> Result> + Send + Sync + 'static, + C: Fn(&str) -> Option + Send + Sync + 'static, +{ + let mut node = FsNode::new(name, 0o755, true); + node.list_fn = Some(Arc::new(list_fn)); + node.child_fn = Some(Arc::new(child_fn)); + node +} + +// ═══════════════════════════════════════════════════════════════════ +// File Options +// ═══════════════════════════════════════════════════════════════════ + +/// Options for configuring file nodes. +pub enum FileOpt { + Read(ReadFn), + Write(WriteFn), + Rdwr(RdwrFn), +} + +/// Create a read handler. +pub fn read(f: F) -> Vec +where + F: Fn() -> Result> + Send + Sync + 'static, +{ + vec![FileOpt::Read(Arc::new(f))] +} + +/// Create a write handler. +pub fn write(f: F) -> Vec +where + F: Fn(&[u8]) -> Result<()> + Send + Sync + 'static, +{ + vec![FileOpt::Write(Arc::new(f))] +} + +/// Create a rdwr handler. +pub fn rdwr(f: F) -> Vec +where + F: Fn(&[u8]) -> Result> + Send + Sync + 'static, +{ + vec![FileOpt::Rdwr(Arc::new(f))] +} + +/// Create combined read + write handlers. +pub fn rw(read_f: R, write_f: W) -> Vec +where + R: Fn() -> Result> + Send + Sync + 'static, + W: Fn(&[u8]) -> Result<()> + Send + Sync + 'static, +{ + vec![ + FileOpt::Read(Arc::new(read_f)), + FileOpt::Write(Arc::new(write_f)), + ] +} + +// ═══════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_static_tree() { + let root = dir("/", vec![ + file("version", 0o444, read(|| Ok(b"1.0\n".to_vec()))), + dir("subdir", vec![ + file("info", 0o444, read(|| Ok(b"hello\n".to_vec()))), + ]), + ]); + + // Walk to version + let v = root.walk("version").unwrap(); + assert_eq!(v.name, "version"); + assert_eq!(v.read().unwrap(), b"1.0\n"); + + // Walk to subdir/info + let info = root.walk("subdir/info").unwrap(); + assert_eq!(info.read().unwrap(), b"hello\n"); + + // List root + let names = root.list().unwrap(); + assert!(names.contains(&"version".to_string())); + assert!(names.contains(&"subdir".to_string())); + } + + #[test] + fn test_dynamic_children() { + let items = vec!["alice".to_string(), "bob".to_string()]; + let items_clone = items.clone(); + + let root = dir("/", vec![ + each("users", + move || Ok(items_clone.clone()), + |name| { + let n = name.to_string(); + let n2 = n.clone(); + Some(dir(&n, vec![ + file("name", 0o444, read(move || Ok(format!("{}\n", n2).into_bytes()))), + ])) + }, + ), + ]); + + // List users + let users = root.walk("users").unwrap(); + let names = users.list().unwrap(); + assert!(names.contains(&"alice".to_string())); + assert!(names.contains(&"bob".to_string())); + + // Read alice's name + let alice_name = root.walk("users/alice/name").unwrap(); + assert_eq!(alice_name.read().unwrap(), b"alice\n"); + } + + #[test] + fn test_write() { + use std::sync::Mutex; + let data = Arc::new(Mutex::new(String::new())); + let data_clone = data.clone(); + + let root = dir("/", vec![ + file("ctl", 0o222, write(move |d| { + *data_clone.lock().unwrap() = String::from_utf8_lossy(d).to_string(); + Ok(()) + })), + ]); + + let ctl = root.walk("ctl").unwrap(); + ctl.write(b"hello").unwrap(); + assert_eq!(*data.lock().unwrap(), "hello"); + } + + #[test] + fn test_rdwr() { + let root = dir("/", vec![ + file("echo", 0o666, rdwr(|d| { + let mut response = b"got: ".to_vec(); + response.extend_from_slice(d); + Ok(response) + })), + ]); + + let echo = root.walk("echo").unwrap(); + let resp = echo.rdwr(b"test").unwrap(); + assert_eq!(resp, b"got: test"); + } +}